Coverage for little_loops / design_tokens.py: 0%

152 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-04 12:21 -0500

1"""Design token loader and renderers for little-loops artifact-generating loops. 

2 

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. 

7 

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""" 

13 

14from __future__ import annotations 

15 

16import json 

17import sys 

18from dataclasses import dataclass 

19from pathlib import Path 

20from typing import TYPE_CHECKING, Any 

21 

22if TYPE_CHECKING: 

23 from little_loops.config.core import BRConfig 

24 

25 

26@dataclass(frozen=True) 

27class DesignTokens: 

28 """Resolved design token set.""" 

29 

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 

35 

36 

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) 

42 

43 

44def _flatten(obj: Any, prefix: str = "") -> dict[str, Any]: 

45 """Recursively flatten a nested dict to dotted-key -> leaf-value pairs. 

46 

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 

65 

66 

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*. 

74 

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() 

80 

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 

85 

86 

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 

97 

98 ref_name = value[1:-1] 

99 if ref_name in resolving: 

100 raise ValueError(f"Circular token reference: {key} -> {ref_name}") 

101 

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}'") 

127 

128 

129def _resolve_token_root(dt_cfg: Any, base_path: Path) -> Path | None: 

130 """Resolve the directory that holds this project's active token files. 

131 

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) 

135 

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 

143 

144 if active_root.is_dir(): 

145 return active_root 

146 

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 

154 

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 

158 

159 

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. 

165 

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. 

169 

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 

179 

180 base_path = config.project_root / dt_cfg.path 

181 if not base_path.exists(): 

182 return None 

183 

184 token_path = _resolve_token_root(dt_cfg, base_path) 

185 if token_path is None: 

186 return None 

187 

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") 

192 

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) 

196 

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) 

202 

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) 

215 

216 return DesignTokens( 

217 primitives=primitives, 

218 semantic=semantic, 

219 theme=theme_data, 

220 resolved=resolved, 

221 source_path=token_path, 

222 ) 

223 

224 

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. 

228 

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 ) 

240 

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) 

249 

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] = {} 

256 

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 ) 

273 

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 

291 

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 ] 

299 

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("") 

309 

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) 

316 

317 return "\n".join(lines) 

318 

319 

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)