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.
@@ -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)