Coverage for little_loops / design_tokens.py: 0%
152 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:27 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:27 -0500
1"""Design token loader and renderers for little-loops artifact-generating loops.
3Loads a multi-layer token system (primitives → semantic → typography →
4spacing → theme) from JSON files configured in BRConfig.design_tokens,
5resolves {token.reference} aliases, and provides rendering helpers for
6prompts and CSS.
8ENH-1768 introduced profiles: token files live under
9`<path>/<profiles_dir or "profiles">/<active>/` and the loader transparently
10falls back to the legacy flat `<path>/` layout when no profile directory
11exists, so pre-ENH-1768 projects keep working.
12"""
14from __future__ import annotations
16import json
17import sys
18from dataclasses import dataclass
19from pathlib import Path
20from typing import TYPE_CHECKING, Any
22if TYPE_CHECKING:
23 from little_loops.config.core import BRConfig
26@dataclass(frozen=True)
27class DesignTokens:
28 """Resolved design token set."""
30 primitives: dict[str, Any]
31 semantic: dict[str, Any]
32 theme: dict[str, Any]
33 resolved: dict[str, str] # flat dotted-name -> concrete value, post reference-resolution
34 source_path: Path
37def _load_json(path: Path) -> dict[str, Any]:
38 if not path.exists():
39 return {}
40 with path.open() as f:
41 return json.load(f)
44def _flatten(obj: Any, prefix: str = "") -> dict[str, Any]:
45 """Recursively flatten a nested dict to dotted-key -> leaf-value pairs.
47 Supports both legacy flat layout (``{"key": "value"}``) and W3C DTCG
48 format (``{"key": {"$value": "value"}}``). When a dict has a ``$value``
49 key it is treated as a leaf and ``$``-prefixed metadata siblings
50 (``$type``, ``$description``, etc.) are ignored.
51 """
52 result: dict[str, Any] = {}
53 if isinstance(obj, dict):
54 if "$value" in obj:
55 result[prefix] = obj["$value"]
56 return result
57 for key, value in obj.items():
58 if key.startswith("$"):
59 continue
60 full_key = f"{prefix}.{key}" if prefix else key
61 result.update(_flatten(value, full_key))
62 else:
63 result[prefix] = obj
64 return result
67def _resolve_references(
68 flat: dict[str, Any],
69 primitives_flat: dict[str, Any],
70 *,
71 _resolving: frozenset[str] | None = None,
72) -> dict[str, str]:
73 """Resolve {token.reference} placeholders in *flat* against *primitives_flat*.
75 Returns a new dict mapping every key to its concrete string value.
76 Raises ValueError on unknown references or circular references.
77 """
78 if _resolving is None:
79 _resolving = frozenset()
81 resolved: dict[str, str] = {}
82 for key, raw in flat.items():
83 resolved[key] = _resolve_value(key, raw, flat, primitives_flat, _resolving)
84 return resolved
87def _resolve_value(
88 key: str,
89 raw: Any,
90 flat: dict[str, Any],
91 primitives_flat: dict[str, Any],
92 resolving: frozenset[str],
93) -> str:
94 value = str(raw)
95 if not (value.startswith("{") and value.endswith("}")):
96 return value
98 ref_name = value[1:-1]
99 if ref_name in resolving:
100 raise ValueError(f"Circular token reference: {key} -> {ref_name}")
102 # Look in primitives first, then the same layer
103 if ref_name in primitives_flat:
104 return str(primitives_flat[ref_name])
105 if ref_name in flat:
106 return _resolve_value(
107 ref_name,
108 flat[ref_name],
109 flat,
110 primitives_flat,
111 resolving | {key},
112 )
113 # Fallback for legacy partially-flattened DTCG inputs where a reference
114 # like {typography.fontFamily.heading} may need a .$value suffix lookup.
115 dv_ref = ref_name + ".$value"
116 if dv_ref in primitives_flat:
117 return str(primitives_flat[dv_ref])
118 if dv_ref in flat:
119 return _resolve_value(
120 dv_ref,
121 flat[dv_ref],
122 flat,
123 primitives_flat,
124 resolving | {key},
125 )
126 raise ValueError(f"Unknown token reference '{ref_name}' in '{key}'")
129def _resolve_token_root(dt_cfg: Any, base_path: Path) -> Path | None:
130 """Resolve the directory that holds this project's active token files.
132 Resolution order (ENH-1768):
133 1. `<base_path>/<profiles_dir or "profiles">/<active>/` (new layout)
134 2. `<base_path>/` (legacy flat layout — pre-ENH-1768 projects)
136 Returns None when neither layout is materialized. When a profiles dir
137 exists but the requested `active` profile is missing, emits a warning
138 to stderr and returns None (degrades cleanly, no crash).
139 """
140 profiles_subdir = dt_cfg.profiles_dir or "profiles"
141 profiles_root = base_path / profiles_subdir
142 active_root = profiles_root / dt_cfg.active
144 if active_root.is_dir():
145 return active_root
147 if profiles_root.is_dir():
148 # Multi-profile layout installed but the requested profile is missing.
149 sys.stderr.write(
150 f"[little-loops] Warning: design_tokens.active='{dt_cfg.active}' "
151 f"but '{active_root}' does not exist; degrading to no tokens.\n"
152 )
153 return None
155 # Legacy flat layout (pre-ENH-1768): treat <base_path> itself as the
156 # token root. Missing files are loaded as empty dicts by _load_json.
157 return base_path
160def load_design_tokens(
161 config: BRConfig,
162 theme: str | None = None,
163) -> DesignTokens | None:
164 """Load and resolve design tokens from the project config.
166 Returns None when design_tokens.enabled is False, the token path does
167 not exist, or the active profile is missing (with a warning).
168 Raises ValueError on circular or unknown token references.
170 Token directory resolution (ENH-1768): the loader first looks for
171 `<path>/<profiles_dir or "profiles">/<active>/`. If that profile
172 directory doesn't exist but a sibling `profiles/` does, a warning is
173 emitted and None is returned. Otherwise the loader falls back to the
174 legacy flat `<path>/` layout for backward compatibility.
175 """
176 dt_cfg = config.design_tokens
177 if not dt_cfg.enabled:
178 return None
180 base_path = config.project_root / dt_cfg.path
181 if not base_path.exists():
182 return None
184 token_path = _resolve_token_root(dt_cfg, base_path)
185 if token_path is None:
186 return None
188 primitives = _load_json(token_path / dt_cfg.primitives_file)
189 semantic = _load_json(token_path / dt_cfg.semantic_file)
190 typography = _load_json(token_path / "typography.json")
191 spacing = _load_json(token_path / "spacing.json")
193 active_theme = theme or dt_cfg.active_theme
194 theme_file = token_path / dt_cfg.themes_dir / f"{active_theme}.json"
195 theme_data = _load_json(theme_file)
197 primitives_flat = _flatten(primitives)
198 semantic_flat = _flatten(semantic)
199 typography_flat = _flatten(typography)
200 spacing_flat = _flatten(spacing)
201 theme_flat = _flatten(theme_data)
203 # Layer order: semantic → typography → spacing → theme override.
204 merged_flat: dict[str, Any] = {
205 **semantic_flat,
206 **typography_flat,
207 **spacing_flat,
208 **theme_flat,
209 }
210 resolved = _resolve_references(merged_flat, primitives_flat)
211 # Also include primitive leaf values in resolved
212 for k, v in primitives_flat.items():
213 if k not in resolved:
214 resolved[k] = str(v)
216 return DesignTokens(
217 primitives=primitives,
218 semantic=semantic,
219 theme=theme_data,
220 resolved=resolved,
221 source_path=token_path,
222 )
225def render_as_prompt_context(tokens: DesignTokens) -> str:
226 """Return a compact markdown snippet listing resolved token values,
227 grouped by semantic role with contrast guardrails.
229 When semantic color tokens exist, raw primitives (color.*) are excluded
230 and the output is grouped so the LLM knows which tokens are for surfaces,
231 text, borders, and actions. Falls back to a flat sorted list when
232 semantic tokens are absent (legacy profiles).
233 """
234 has_semantic_colors = (
235 isinstance(tokens.semantic, dict)
236 and "color" in tokens.semantic
237 and isinstance(tokens.semantic["color"], dict)
238 and any(k in tokens.semantic["color"] for k in ("surface", "text", "border", "action"))
239 )
241 if not has_semantic_colors:
242 lines: list[str] = ["**Design tokens** (resolved values):", "```"]
243 for name, value in sorted(tokens.resolved.items()):
244 if name.startswith("_"):
245 continue
246 lines.append(f"{name}: {value}")
247 lines.append("```")
248 return "\n".join(lines)
250 surfaces: dict[str, str] = {}
251 text_colors: dict[str, str] = {}
252 border_colors: dict[str, str] = {}
253 actions: dict[str, str] = {}
254 typography: dict[str, str] = {}
255 layout: dict[str, str] = {}
257 # Semantic color tokens are flattened as color.<role>.<name>.
258 _SEMANTIC_ROLE_PREFIXES = {
259 "color.surface.": surfaces,
260 "color.text.": text_colors,
261 "color.border.": border_colors,
262 "color.action.": actions,
263 }
264 # Raw primitives to exclude (only semantic color tokens are shown).
265 _PRIMITIVE_COLOR_PREFIXES = (
266 "color.neutral.",
267 "color.brand.",
268 "color.accent.",
269 "color.success.",
270 "color.warning.",
271 "color.danger.",
272 )
274 for name, value in sorted(tokens.resolved.items()):
275 if name.startswith("_"):
276 continue
277 matched = False
278 for prefix, bucket in _SEMANTIC_ROLE_PREFIXES.items():
279 if name.startswith(prefix):
280 bucket[name] = value
281 matched = True
282 break
283 if matched:
284 continue
285 if name.startswith("font."):
286 typography[name] = value
287 elif any(name.startswith(p) for p in ("space.", "radius.", "shadow.", "border.width.")):
288 layout[name] = value
289 elif name.startswith(_PRIMITIVE_COLOR_PREFIXES):
290 continue # raw primitive — covered by semantic tokens above
292 lines = [
293 "**Design tokens** (semantic — each token's role is noted; use the token name, not a raw hex value)",
294 "",
295 "Contrast guardrail: pair color.text.* tokens ON color.surface.* tokens. "
296 "Never use a surface color for text, or a text color for backgrounds.",
297 "",
298 ]
300 def _emit_group(heading: str, items: dict[str, str]) -> None:
301 if not items:
302 return
303 lines.append(f"**{heading}**")
304 lines.append("```")
305 for n, v in items.items():
306 lines.append(f" {n}: {v}")
307 lines.append("```")
308 lines.append("")
310 _emit_group("Surfaces (backgrounds)", surfaces)
311 _emit_group("Text", text_colors)
312 _emit_group("Borders", border_colors)
313 _emit_group("Actions (buttons, links, interactive)", actions)
314 _emit_group("Typography", typography)
315 _emit_group("Layout (spacing, radii, shadows)", layout)
317 return "\n".join(lines)
320def render_as_css_vars(tokens: DesignTokens) -> str:
321 """Return a CSS :root { ... } block declaring all resolved tokens as custom properties."""
322 lines = [":root {"]
323 for name, value in sorted(tokens.resolved.items()):
324 css_name = "--" + name.replace(".", "-")
325 lines.append(f" {css_name}: {value};")
326 lines.append("}")
327 return "\n".join(lines)