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

2

3 Loads a multi-layer token system (primitives → semantic → typography →

4 spacing → theme) from JSON files configured in BRConfig.design_tokens,

5 resolves {token.reference} aliases, and provides rendering helpers for

6 prompts and CSS.

7

8 ENH-1768 introduced profiles: token files live under

9`<path>/<profiles_dir or "profiles">/<active>/` and the loader transparently

10 falls back to the legacy flat `<path>/` layout when no profile directory

11 exists, so pre-ENH-1768 projects keep working.

12"""

13

14 from __future__ import annotations

15

16 import json

17 import sys

18 from dataclasses import dataclass

19 from pathlib import Path

20 from typing import TYPE_CHECKING, Any

21

22 if TYPE_CHECKING:

23 from little_loops.config.core import BRConfig

24

25

26@dataclass(frozen=True)

27 class 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

37 def _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

44 def _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

67 def _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

87 def _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

129 def _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

160 def 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

225 def 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

320 def 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)