Coverage for little_loops / design_tokens.py: 0%
79 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -0500
1"""Design token loader and renderers for little-loops artifact-generating loops.
3Loads a three-layer token system (primitives → semantic → theme) from JSON files
4configured in BRConfig.design_tokens, resolves {token.reference} aliases, and
5provides rendering helpers for prompts and CSS.
6"""
8from __future__ import annotations
10import json
11from dataclasses import dataclass
12from pathlib import Path
13from typing import TYPE_CHECKING, Any
15if TYPE_CHECKING:
16 from little_loops.config.core import BRConfig
19@dataclass(frozen=True)
20class DesignTokens:
21 """Resolved design token set."""
23 primitives: dict[str, Any]
24 semantic: dict[str, Any]
25 theme: dict[str, Any]
26 resolved: dict[str, str] # flat dotted-name -> concrete value, post reference-resolution
27 source_path: Path
30def _load_json(path: Path) -> dict[str, Any]:
31 if not path.exists():
32 return {}
33 with path.open() as f:
34 return json.load(f)
37def _flatten(obj: Any, prefix: str = "") -> dict[str, Any]:
38 """Recursively flatten a nested dict to dotted-key -> leaf-value pairs."""
39 result: dict[str, Any] = {}
40 if isinstance(obj, dict):
41 for key, value in obj.items():
42 full_key = f"{prefix}.{key}" if prefix else key
43 result.update(_flatten(value, full_key))
44 else:
45 result[prefix] = obj
46 return result
49def _resolve_references(
50 flat: dict[str, Any],
51 primitives_flat: dict[str, Any],
52 *,
53 _resolving: frozenset[str] | None = None,
54) -> dict[str, str]:
55 """Resolve {token.reference} placeholders in *flat* against *primitives_flat*.
57 Returns a new dict mapping every key to its concrete string value.
58 Raises ValueError on unknown references or circular references.
59 """
60 if _resolving is None:
61 _resolving = frozenset()
63 resolved: dict[str, str] = {}
64 for key, raw in flat.items():
65 resolved[key] = _resolve_value(key, raw, flat, primitives_flat, _resolving)
66 return resolved
69def _resolve_value(
70 key: str,
71 raw: Any,
72 flat: dict[str, Any],
73 primitives_flat: dict[str, Any],
74 resolving: frozenset[str],
75) -> str:
76 value = str(raw)
77 if not (value.startswith("{") and value.endswith("}")):
78 return value
80 ref_name = value[1:-1]
81 if ref_name in resolving:
82 raise ValueError(f"Circular token reference: {key} -> {ref_name}")
84 # Look in primitives first, then the same layer
85 if ref_name in primitives_flat:
86 return str(primitives_flat[ref_name])
87 if ref_name in flat:
88 return _resolve_value(
89 ref_name,
90 flat[ref_name],
91 flat,
92 primitives_flat,
93 resolving | {key},
94 )
95 raise ValueError(f"Unknown token reference '{ref_name}' in '{key}'")
98def load_design_tokens(
99 config: BRConfig,
100 theme: str | None = None,
101) -> DesignTokens | None:
102 """Load and resolve design tokens from the project config.
104 Returns None when design_tokens.enabled is False or the token path does not exist.
105 Raises ValueError on circular or unknown token references.
106 """
107 dt_cfg = config.design_tokens
108 if not dt_cfg.enabled:
109 return None
111 token_path = config.project_root / dt_cfg.path
112 if not token_path.exists():
113 return None
115 primitives = _load_json(token_path / dt_cfg.primitives_file)
116 semantic = _load_json(token_path / dt_cfg.semantic_file)
118 active_theme = theme or dt_cfg.active_theme
119 theme_file = token_path / dt_cfg.themes_dir / f"{active_theme}.json"
120 theme_data = _load_json(theme_file)
122 primitives_flat = _flatten(primitives)
123 semantic_flat = _flatten(semantic)
124 theme_flat = _flatten(theme_data)
126 # Layer: theme overrides semantic, semantic is resolved against primitives
127 merged_flat: dict[str, Any] = {**semantic_flat, **theme_flat}
128 resolved = _resolve_references(merged_flat, primitives_flat)
129 # Also include primitive leaf values in resolved
130 for k, v in primitives_flat.items():
131 if k not in resolved:
132 resolved[k] = str(v)
134 return DesignTokens(
135 primitives=primitives,
136 semantic=semantic,
137 theme=theme_data,
138 resolved=resolved,
139 source_path=token_path,
140 )
143def render_as_prompt_context(tokens: DesignTokens) -> str:
144 """Return a compact markdown snippet listing resolved token values."""
145 lines = ["**Design tokens** (resolved values):"]
146 lines.append("```")
147 for name, value in sorted(tokens.resolved.items()):
148 lines.append(f"{name}: {value}")
149 lines.append("```")
150 return "\n".join(lines)
153def render_as_css_vars(tokens: DesignTokens) -> str:
154 """Return a CSS :root { ... } block declaring all resolved tokens as custom properties."""
155 lines = [":root {"]
156 for name, value in sorted(tokens.resolved.items()):
157 css_name = "--" + name.replace(".", "-")
158 lines.append(f" {css_name}: {value};")
159 lines.append("}")
160 return "\n".join(lines)