loki-mode 6.79.0 → 6.80.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/magic/__init__.py +7 -0
- package/magic/core/__init__.py +0 -0
- package/magic/core/debate.py +781 -0
- package/magic/core/design_tokens.py +469 -0
- package/magic/core/freshness.py +86 -0
- package/magic/core/generator.py +755 -0
- package/magic/core/memory_bridge.py +220 -0
- package/magic/core/prd_scanner.py +265 -0
- package/magic/core/registry.py +340 -0
- package/magic/core/spec.py +337 -0
- package/magic/debate/personas/a11y.md +95 -0
- package/magic/debate/personas/conservative.md +83 -0
- package/magic/debate/personas/creative.md +73 -0
- package/magic/debate/personas/performance.md +93 -0
- package/magic/registry/schema.json +38 -0
- package/magic/testing/__init__.py +0 -0
- package/magic/testing/snapshot.py +224 -0
- package/magic/testing/test_generator.py +453 -0
- package/magic/tokens/README.md +83 -0
- package/magic/tokens/defaults.json +59 -0
- package/mcp/__init__.py +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""Design token management for Magic Modules.
|
|
2
|
+
|
|
3
|
+
Tokens guide component generation so output matches Loki Mode's design
|
|
4
|
+
language. Loaded from magic/tokens/defaults.json, project overrides at
|
|
5
|
+
.loki/magic/tokens.json, and extracted from existing codebase.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Standard Tailwind spacing scale in pixels. Tailwind uses 0.25rem per step
|
|
16
|
+
# (assuming 16px root), so step N maps to N * 4px.
|
|
17
|
+
_TAILWIND_SPACING_PX = 4
|
|
18
|
+
|
|
19
|
+
# Common spacing buckets we care about, ordered for nearest-match.
|
|
20
|
+
_SPACING_BUCKETS = [
|
|
21
|
+
("xs", 4),
|
|
22
|
+
("sm", 8),
|
|
23
|
+
("md", 12),
|
|
24
|
+
("lg", 16),
|
|
25
|
+
("xl", 24),
|
|
26
|
+
("2xl", 32),
|
|
27
|
+
("3xl", 48),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# Files/globs we scan during codebase extraction.
|
|
31
|
+
# Generic patterns so this works for any frontend project layout,
|
|
32
|
+
# not just loki-mode's (web-app/, dashboard-ui/).
|
|
33
|
+
_CSS_GLOBS = [
|
|
34
|
+
"**/*.css",
|
|
35
|
+
"**/*.scss",
|
|
36
|
+
"**/loki-unified-styles.js",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
_TSX_GLOBS = [
|
|
40
|
+
"**/*.tsx",
|
|
41
|
+
"**/*.jsx",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# Paths to skip during extraction (build outputs, deps, VCS, caches).
|
|
45
|
+
_EXCLUDE_PARTS = {
|
|
46
|
+
"node_modules",
|
|
47
|
+
".git",
|
|
48
|
+
"dist",
|
|
49
|
+
"build",
|
|
50
|
+
".next",
|
|
51
|
+
".nuxt",
|
|
52
|
+
"out",
|
|
53
|
+
"coverage",
|
|
54
|
+
".venv",
|
|
55
|
+
"venv",
|
|
56
|
+
"__pycache__",
|
|
57
|
+
".cache",
|
|
58
|
+
".parcel-cache",
|
|
59
|
+
".turbo",
|
|
60
|
+
".vercel",
|
|
61
|
+
".svelte-kit",
|
|
62
|
+
"target",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class DesignTokens:
|
|
67
|
+
"""Load, extract, and render design tokens for component generation."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, project_dir: str = "."):
|
|
70
|
+
self.project_dir = Path(project_dir).resolve()
|
|
71
|
+
self._tokens: Optional[dict] = None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def tokens(self) -> dict:
|
|
75
|
+
if self._tokens is None:
|
|
76
|
+
self._tokens = self.load()
|
|
77
|
+
return self._tokens
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------ load
|
|
80
|
+
def load(self) -> dict:
|
|
81
|
+
"""Load tokens with precedence: defaults < project override.
|
|
82
|
+
|
|
83
|
+
Defaults from magic/tokens/defaults.json (packaged).
|
|
84
|
+
Project override from .loki/magic/tokens.json (user-editable).
|
|
85
|
+
"""
|
|
86
|
+
merged = self._load_defaults()
|
|
87
|
+
|
|
88
|
+
override_path = self.project_dir / ".loki" / "magic" / "tokens.json"
|
|
89
|
+
if override_path.exists():
|
|
90
|
+
try:
|
|
91
|
+
with override_path.open("r", encoding="utf-8") as fh:
|
|
92
|
+
override = json.load(fh)
|
|
93
|
+
except (json.JSONDecodeError, OSError):
|
|
94
|
+
override = {}
|
|
95
|
+
merged = self._deep_merge(merged, override)
|
|
96
|
+
|
|
97
|
+
self._tokens = merged
|
|
98
|
+
return merged
|
|
99
|
+
|
|
100
|
+
def _load_defaults(self) -> dict:
|
|
101
|
+
"""Locate defaults.json. Prefer packaged path next to this module,
|
|
102
|
+
fall back to project-local magic/tokens/defaults.json."""
|
|
103
|
+
candidates = [
|
|
104
|
+
Path(__file__).resolve().parent.parent / "tokens" / "defaults.json",
|
|
105
|
+
self.project_dir / "magic" / "tokens" / "defaults.json",
|
|
106
|
+
]
|
|
107
|
+
for path in candidates:
|
|
108
|
+
if path.exists():
|
|
109
|
+
try:
|
|
110
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
111
|
+
return json.load(fh)
|
|
112
|
+
except (json.JSONDecodeError, OSError):
|
|
113
|
+
continue
|
|
114
|
+
# Absolute fallback: empty scaffold so callers never crash.
|
|
115
|
+
return {
|
|
116
|
+
"colors": {},
|
|
117
|
+
"spacing": {},
|
|
118
|
+
"typography": {},
|
|
119
|
+
"radii": {},
|
|
120
|
+
"shadows": {},
|
|
121
|
+
"motion": {},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
126
|
+
"""Merge override into base, recursing into dicts."""
|
|
127
|
+
result = dict(base)
|
|
128
|
+
for key, value in override.items():
|
|
129
|
+
if (
|
|
130
|
+
key in result
|
|
131
|
+
and isinstance(result[key], dict)
|
|
132
|
+
and isinstance(value, dict)
|
|
133
|
+
):
|
|
134
|
+
result[key] = DesignTokens._deep_merge(result[key], value)
|
|
135
|
+
else:
|
|
136
|
+
result[key] = value
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
# --------------------------------------------------------------- extract
|
|
140
|
+
def extract_from_codebase(self, save: bool = False) -> dict:
|
|
141
|
+
"""Scan existing components and extract observed tokens.
|
|
142
|
+
|
|
143
|
+
Scans:
|
|
144
|
+
- web-app/src/index.css for CSS custom properties
|
|
145
|
+
- dashboard-ui/loki-unified-styles.js for design system vars
|
|
146
|
+
- Tailwind classes in .tsx files for spacing/color patterns
|
|
147
|
+
|
|
148
|
+
Returns the observed token set. If save=True, writes to
|
|
149
|
+
.loki/magic/tokens.json.
|
|
150
|
+
"""
|
|
151
|
+
observed: dict = {
|
|
152
|
+
"colors": {},
|
|
153
|
+
"spacing": {},
|
|
154
|
+
"typography": {},
|
|
155
|
+
"radii": {},
|
|
156
|
+
"shadows": {},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# --color-primary: #553DE9;
|
|
160
|
+
css_var_re = re.compile(r"--([a-zA-Z0-9_-]+)\s*:\s*([^;]+);")
|
|
161
|
+
# Tailwind spacing utilities like p-4, m-2, gap-6, px-3
|
|
162
|
+
tw_spacing_re = re.compile(
|
|
163
|
+
r"\b(?:p|m|gap|px|py|mx|my|pt|pb|pl|pr|mt|mb|ml|mr|space-x|space-y)-(\d+)\b"
|
|
164
|
+
)
|
|
165
|
+
# Hex colors #abc / #aabbcc / #aabbccdd
|
|
166
|
+
hex_color_re = re.compile(r"#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b")
|
|
167
|
+
# font-family declarations
|
|
168
|
+
font_family_re = re.compile(
|
|
169
|
+
r"font-family\s*:\s*([^;]+);", re.IGNORECASE
|
|
170
|
+
)
|
|
171
|
+
# border-radius values (8px, 0.5rem, 9999px)
|
|
172
|
+
radius_re = re.compile(
|
|
173
|
+
r"border-radius\s*:\s*([0-9.]+(?:px|rem|em|%)|9999px);",
|
|
174
|
+
re.IGNORECASE,
|
|
175
|
+
)
|
|
176
|
+
# box-shadow declarations
|
|
177
|
+
shadow_re = re.compile(
|
|
178
|
+
r"box-shadow\s*:\s*([^;]+);", re.IGNORECASE
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
color_counter: Counter = Counter()
|
|
182
|
+
spacing_counter: Counter = Counter()
|
|
183
|
+
font_counter: Counter = Counter()
|
|
184
|
+
radius_counter: Counter = Counter()
|
|
185
|
+
shadow_counter: Counter = Counter()
|
|
186
|
+
|
|
187
|
+
# ---- Scan CSS / JS style files -----------------------------------
|
|
188
|
+
for pattern in _CSS_GLOBS:
|
|
189
|
+
for path in self._glob(pattern):
|
|
190
|
+
content = self._read(path)
|
|
191
|
+
if not content:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
# Named custom properties go straight into observed map.
|
|
195
|
+
for name, value in css_var_re.findall(content):
|
|
196
|
+
value = value.strip()
|
|
197
|
+
key = name.lower()
|
|
198
|
+
if self._looks_like_color(value):
|
|
199
|
+
observed["colors"][key] = value
|
|
200
|
+
color_counter[value] += 1
|
|
201
|
+
elif self._looks_like_length(value):
|
|
202
|
+
observed["spacing"].setdefault(key, value)
|
|
203
|
+
elif "font" in key:
|
|
204
|
+
observed["typography"].setdefault(key, value)
|
|
205
|
+
|
|
206
|
+
for match in hex_color_re.findall(content):
|
|
207
|
+
color_counter["#" + match.upper()] += 1
|
|
208
|
+
|
|
209
|
+
for match in font_family_re.findall(content):
|
|
210
|
+
font_counter[match.strip()] += 1
|
|
211
|
+
|
|
212
|
+
for match in radius_re.findall(content):
|
|
213
|
+
radius_counter[match.strip()] += 1
|
|
214
|
+
|
|
215
|
+
for match in shadow_re.findall(content):
|
|
216
|
+
shadow_counter[match.strip()] += 1
|
|
217
|
+
|
|
218
|
+
# ---- Scan TSX/JSX for Tailwind spacing usage ---------------------
|
|
219
|
+
for pattern in _TSX_GLOBS:
|
|
220
|
+
for path in self._glob(pattern):
|
|
221
|
+
content = self._read(path)
|
|
222
|
+
if not content:
|
|
223
|
+
continue
|
|
224
|
+
for step in tw_spacing_re.findall(content):
|
|
225
|
+
try:
|
|
226
|
+
px = int(step) * _TAILWIND_SPACING_PX
|
|
227
|
+
except ValueError:
|
|
228
|
+
continue
|
|
229
|
+
spacing_counter[f"{px}px"] += 1
|
|
230
|
+
for match in hex_color_re.findall(content):
|
|
231
|
+
color_counter["#" + match.upper()] += 1
|
|
232
|
+
|
|
233
|
+
# ---- Promote most-common raw hits into observed sets -------------
|
|
234
|
+
for value, _count in color_counter.most_common(24):
|
|
235
|
+
if value not in observed["colors"].values():
|
|
236
|
+
key = self._slugify_color(value)
|
|
237
|
+
observed["colors"].setdefault(key, value)
|
|
238
|
+
|
|
239
|
+
for value, _count in spacing_counter.most_common(12):
|
|
240
|
+
bucket = self._nearest_spacing_bucket(value)
|
|
241
|
+
if bucket and bucket not in observed["spacing"]:
|
|
242
|
+
observed["spacing"][bucket] = value
|
|
243
|
+
|
|
244
|
+
for value, _count in font_counter.most_common(4):
|
|
245
|
+
key = "font-sans" if "sans" in value.lower() or "inter" in value.lower() else (
|
|
246
|
+
"font-mono" if "mono" in value.lower() else "font-serif"
|
|
247
|
+
)
|
|
248
|
+
observed["typography"].setdefault(key, value)
|
|
249
|
+
|
|
250
|
+
for value, count in radius_counter.most_common(6):
|
|
251
|
+
# label by size: 4px -> sm, 8px -> lg, 9999px -> full
|
|
252
|
+
label = self._label_radius(value)
|
|
253
|
+
observed["radii"].setdefault(label, value)
|
|
254
|
+
|
|
255
|
+
for value, _count in shadow_counter.most_common(4):
|
|
256
|
+
# Just record the first few under size-ordered slots.
|
|
257
|
+
for slot in ("sm", "md", "lg"):
|
|
258
|
+
if slot not in observed["shadows"]:
|
|
259
|
+
observed["shadows"][slot] = value
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
if save:
|
|
263
|
+
out_dir = self.project_dir / ".loki" / "magic"
|
|
264
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
out_path = out_dir / "tokens.json"
|
|
266
|
+
with out_path.open("w", encoding="utf-8") as fh:
|
|
267
|
+
json.dump(observed, fh, indent=2, sort_keys=True)
|
|
268
|
+
fh.write("\n")
|
|
269
|
+
|
|
270
|
+
return observed
|
|
271
|
+
|
|
272
|
+
# ------------------------------------------------------------- renderers
|
|
273
|
+
def to_tailwind_config(self) -> dict:
|
|
274
|
+
"""Convert tokens to a Tailwind theme extension dict."""
|
|
275
|
+
t = self.tokens
|
|
276
|
+
colors = dict(t.get("colors", {}))
|
|
277
|
+
spacing = dict(t.get("spacing", {}))
|
|
278
|
+
fonts = t.get("typography", {})
|
|
279
|
+
|
|
280
|
+
font_family = {}
|
|
281
|
+
font_size = {}
|
|
282
|
+
for key, value in fonts.items():
|
|
283
|
+
if key.startswith("font-"):
|
|
284
|
+
family_key = key[len("font-"):]
|
|
285
|
+
font_family[family_key] = [
|
|
286
|
+
piece.strip() for piece in value.split(",")
|
|
287
|
+
]
|
|
288
|
+
elif key.startswith("size-"):
|
|
289
|
+
size_key = key[len("size-"):]
|
|
290
|
+
font_size[size_key] = value
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
"theme": {
|
|
294
|
+
"extend": {
|
|
295
|
+
"colors": colors,
|
|
296
|
+
"spacing": spacing,
|
|
297
|
+
"fontFamily": font_family,
|
|
298
|
+
"fontSize": font_size,
|
|
299
|
+
"borderRadius": dict(t.get("radii", {})),
|
|
300
|
+
"boxShadow": dict(t.get("shadows", {})),
|
|
301
|
+
"transitionDuration": {
|
|
302
|
+
k.replace("duration-", ""): v
|
|
303
|
+
for k, v in t.get("motion", {}).items()
|
|
304
|
+
if k.startswith("duration-")
|
|
305
|
+
},
|
|
306
|
+
"transitionTimingFunction": {
|
|
307
|
+
k.replace("ease-", ""): v
|
|
308
|
+
for k, v in t.get("motion", {}).items()
|
|
309
|
+
if k.startswith("ease-")
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
def to_css_variables(self) -> str:
|
|
316
|
+
"""Convert tokens to :root { --var: value } CSS."""
|
|
317
|
+
t = self.tokens
|
|
318
|
+
lines = [":root {"]
|
|
319
|
+
prefix_map = [
|
|
320
|
+
("color", "colors"),
|
|
321
|
+
("space", "spacing"),
|
|
322
|
+
("font", "typography"),
|
|
323
|
+
("radius", "radii"),
|
|
324
|
+
("shadow", "shadows"),
|
|
325
|
+
("motion", "motion"),
|
|
326
|
+
]
|
|
327
|
+
for prefix, group_key in prefix_map:
|
|
328
|
+
group = t.get(group_key, {})
|
|
329
|
+
if not group:
|
|
330
|
+
continue
|
|
331
|
+
lines.append(f" /* {group_key} */")
|
|
332
|
+
for key, value in group.items():
|
|
333
|
+
safe_key = re.sub(r"[^a-zA-Z0-9-]", "-", str(key)).lower()
|
|
334
|
+
lines.append(f" --{prefix}-{safe_key}: {value};")
|
|
335
|
+
lines.append("}")
|
|
336
|
+
return "\n".join(lines) + "\n"
|
|
337
|
+
|
|
338
|
+
def to_prompt_context(self) -> str:
|
|
339
|
+
"""Format tokens as a concise context block for AI generation prompts."""
|
|
340
|
+
t = self.tokens
|
|
341
|
+
colors = t.get("colors", {})
|
|
342
|
+
spacing = t.get("spacing", {})
|
|
343
|
+
typography = t.get("typography", {})
|
|
344
|
+
radii = t.get("radii", {})
|
|
345
|
+
|
|
346
|
+
color_items = ", ".join(
|
|
347
|
+
f"{k}={v}" for k, v in list(colors.items())[:10]
|
|
348
|
+
)
|
|
349
|
+
spacing_items = ", ".join(
|
|
350
|
+
f"{k}={v}" for k, v in spacing.items()
|
|
351
|
+
)
|
|
352
|
+
radii_items = ", ".join(
|
|
353
|
+
f"{k}={v}" for k, v in radii.items()
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
font_sans = typography.get("font-sans", "")
|
|
357
|
+
font_mono = typography.get("font-mono", "")
|
|
358
|
+
typo_summary_parts = []
|
|
359
|
+
if font_sans:
|
|
360
|
+
typo_summary_parts.append(f"{font_sans.split(',')[0].strip()} (body)")
|
|
361
|
+
if font_mono:
|
|
362
|
+
typo_summary_parts.append(f"{font_mono.split(',')[0].strip()} (code)")
|
|
363
|
+
typo_summary = ", ".join(typo_summary_parts) if typo_summary_parts else "default stack"
|
|
364
|
+
|
|
365
|
+
lines = [
|
|
366
|
+
"DESIGN TOKENS:",
|
|
367
|
+
f"Colors: {color_items}" if color_items else "Colors: (none defined)",
|
|
368
|
+
f"Spacing: {spacing_items}" if spacing_items else "Spacing: (none defined)",
|
|
369
|
+
f"Typography: {typo_summary}",
|
|
370
|
+
f"Radii: {radii_items}" if radii_items else "Radii: (none defined)",
|
|
371
|
+
]
|
|
372
|
+
return "\n".join(lines)
|
|
373
|
+
|
|
374
|
+
# --------------------------------------------------------------- helpers
|
|
375
|
+
def _glob(self, pattern: str):
|
|
376
|
+
"""Return matching files inside the project dir for a glob pattern.
|
|
377
|
+
|
|
378
|
+
Skips build outputs, dependency dirs, caches, and VCS metadata so
|
|
379
|
+
generic patterns like ``**/*.tsx`` don't pull in vendored code.
|
|
380
|
+
"""
|
|
381
|
+
try:
|
|
382
|
+
matches = self.project_dir.glob(pattern)
|
|
383
|
+
except (ValueError, OSError):
|
|
384
|
+
return []
|
|
385
|
+
results = []
|
|
386
|
+
for path in matches:
|
|
387
|
+
try:
|
|
388
|
+
rel = path.relative_to(self.project_dir)
|
|
389
|
+
except ValueError:
|
|
390
|
+
rel = path
|
|
391
|
+
if any(part in _EXCLUDE_PARTS for part in rel.parts):
|
|
392
|
+
continue
|
|
393
|
+
results.append(path)
|
|
394
|
+
results.sort()
|
|
395
|
+
return results
|
|
396
|
+
|
|
397
|
+
@staticmethod
|
|
398
|
+
def _read(path: Path) -> str:
|
|
399
|
+
try:
|
|
400
|
+
return path.read_text(encoding="utf-8", errors="ignore")
|
|
401
|
+
except OSError:
|
|
402
|
+
return ""
|
|
403
|
+
|
|
404
|
+
@staticmethod
|
|
405
|
+
def _looks_like_color(value: str) -> bool:
|
|
406
|
+
v = value.strip().lower()
|
|
407
|
+
if v.startswith("#") and re.fullmatch(r"#[0-9a-f]{3,8}", v):
|
|
408
|
+
return True
|
|
409
|
+
return v.startswith(("rgb(", "rgba(", "hsl(", "hsla("))
|
|
410
|
+
|
|
411
|
+
@staticmethod
|
|
412
|
+
def _looks_like_length(value: str) -> bool:
|
|
413
|
+
v = value.strip().lower()
|
|
414
|
+
return bool(re.fullmatch(r"[0-9.]+(px|rem|em|%)", v))
|
|
415
|
+
|
|
416
|
+
@staticmethod
|
|
417
|
+
def _slugify_color(value: str) -> str:
|
|
418
|
+
v = value.strip().lstrip("#").lower()
|
|
419
|
+
return f"c-{v}"
|
|
420
|
+
|
|
421
|
+
@staticmethod
|
|
422
|
+
def _nearest_spacing_bucket(value: str) -> Optional[str]:
|
|
423
|
+
match = re.match(r"([0-9.]+)px", value)
|
|
424
|
+
if not match:
|
|
425
|
+
return None
|
|
426
|
+
try:
|
|
427
|
+
px = float(match.group(1))
|
|
428
|
+
except ValueError:
|
|
429
|
+
return None
|
|
430
|
+
best_label = None
|
|
431
|
+
best_diff = None
|
|
432
|
+
for label, bucket_px in _SPACING_BUCKETS:
|
|
433
|
+
diff = abs(px - bucket_px)
|
|
434
|
+
if best_diff is None or diff < best_diff:
|
|
435
|
+
best_label = label
|
|
436
|
+
best_diff = diff
|
|
437
|
+
return best_label
|
|
438
|
+
|
|
439
|
+
@staticmethod
|
|
440
|
+
def _label_radius(value: str) -> str:
|
|
441
|
+
v = value.strip().lower()
|
|
442
|
+
if v in ("9999px", "50%"):
|
|
443
|
+
return "full"
|
|
444
|
+
match = re.match(r"([0-9.]+)px", v)
|
|
445
|
+
if not match:
|
|
446
|
+
return "md"
|
|
447
|
+
try:
|
|
448
|
+
px = float(match.group(1))
|
|
449
|
+
except ValueError:
|
|
450
|
+
return "md"
|
|
451
|
+
if px <= 4:
|
|
452
|
+
return "sm"
|
|
453
|
+
if px <= 6:
|
|
454
|
+
return "md"
|
|
455
|
+
if px <= 9:
|
|
456
|
+
return "lg"
|
|
457
|
+
return "xl"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
__all__ = ["DesignTokens"]
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# ---------------------------------------------------------------------------
|
|
464
|
+
# Module-level convenience API
|
|
465
|
+
# ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
def load_tokens(project_dir: str = ".") -> dict:
|
|
468
|
+
"""Convenience wrapper returning DesignTokens(project_dir).tokens."""
|
|
469
|
+
return DesignTokens(project_dir).tokens
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""SHA-based freshness checking (MagicModules pattern).
|
|
2
|
+
|
|
3
|
+
When a spec changes, implementations regenerate. When a spec is unchanged,
|
|
4
|
+
cached implementations are reused. This is the core efficiency mechanism.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
HASH_HEADER_PATTERN = re.compile(
|
|
14
|
+
r"^(?://|#|/\*)\s*LOKI-MAGIC-HASH:\s*([a-f0-9]{64})",
|
|
15
|
+
re.MULTILINE,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compute_spec_hash(spec_content: str) -> str:
|
|
20
|
+
"""Return the SHA256 hex digest of the spec markdown content."""
|
|
21
|
+
return hashlib.sha256(spec_content.encode("utf-8")).hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def extract_hash(generated_code: str) -> Optional[str]:
|
|
25
|
+
"""Extract the LOKI-MAGIC-HASH value from a generated code header.
|
|
26
|
+
|
|
27
|
+
Returns None when no hash header is present.
|
|
28
|
+
"""
|
|
29
|
+
match = HASH_HEADER_PATTERN.search(generated_code)
|
|
30
|
+
return match.group(1) if match else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_fresh(spec_content: str, generated_code: str) -> bool:
|
|
34
|
+
"""Return True when generated code's embedded hash matches current spec."""
|
|
35
|
+
expected = compute_spec_hash(spec_content)
|
|
36
|
+
actual = extract_hash(generated_code)
|
|
37
|
+
return actual == expected
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def prepend_hash_header(
|
|
41
|
+
code: str,
|
|
42
|
+
spec_content: str,
|
|
43
|
+
comment_style: str = "//",
|
|
44
|
+
) -> str:
|
|
45
|
+
"""Prepend a hash comment header to generated code.
|
|
46
|
+
|
|
47
|
+
comment_style:
|
|
48
|
+
'//' for JS/TS/Dart
|
|
49
|
+
'#' for Python/YAML
|
|
50
|
+
'/*' for CSS (or any C-style block comment language)
|
|
51
|
+
"""
|
|
52
|
+
h = compute_spec_hash(spec_content)
|
|
53
|
+
if comment_style == "//":
|
|
54
|
+
header = (
|
|
55
|
+
f"// LOKI-MAGIC-HASH: {h}\n"
|
|
56
|
+
"// Auto-generated from spec. Edit spec, not this file.\n\n"
|
|
57
|
+
)
|
|
58
|
+
elif comment_style == "#":
|
|
59
|
+
header = (
|
|
60
|
+
f"# LOKI-MAGIC-HASH: {h}\n"
|
|
61
|
+
"# Auto-generated from spec. Edit spec, not this file.\n\n"
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
header = (
|
|
65
|
+
f"/* LOKI-MAGIC-HASH: {h} */\n"
|
|
66
|
+
"/* Auto-generated from spec. Edit spec, not this file. */\n\n"
|
|
67
|
+
)
|
|
68
|
+
return header + code
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def needs_regen(spec_path: Path, generated_path: Path) -> bool:
|
|
72
|
+
"""Return True when the generated file must be regenerated.
|
|
73
|
+
|
|
74
|
+
Rules:
|
|
75
|
+
- Missing generated file -> regenerate.
|
|
76
|
+
- Missing spec file -> cannot regenerate (False).
|
|
77
|
+
- Hash mismatch or no hash -> regenerate.
|
|
78
|
+
- Hash matches current spec -> do not regenerate.
|
|
79
|
+
"""
|
|
80
|
+
if not generated_path.exists():
|
|
81
|
+
return True
|
|
82
|
+
if not spec_path.exists():
|
|
83
|
+
return False
|
|
84
|
+
spec = spec_path.read_text(encoding="utf-8")
|
|
85
|
+
gen = generated_path.read_text(encoding="utf-8")
|
|
86
|
+
return not is_fresh(spec, gen)
|