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

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

2 

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

7 

8from __future__ import annotations 

9 

10import json 

11from dataclasses import dataclass 

12from pathlib import Path 

13from typing import TYPE_CHECKING, Any 

14 

15if TYPE_CHECKING: 

16 from little_loops.config.core import BRConfig 

17 

18 

19@dataclass(frozen=True) 

20class DesignTokens: 

21 """Resolved design token set.""" 

22 

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 

28 

29 

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) 

35 

36 

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 

47 

48 

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

56 

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

62 

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 

67 

68 

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 

79 

80 ref_name = value[1:-1] 

81 if ref_name in resolving: 

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

83 

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

96 

97 

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. 

103 

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 

110 

111 token_path = config.project_root / dt_cfg.path 

112 if not token_path.exists(): 

113 return None 

114 

115 primitives = _load_json(token_path / dt_cfg.primitives_file) 

116 semantic = _load_json(token_path / dt_cfg.semantic_file) 

117 

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) 

121 

122 primitives_flat = _flatten(primitives) 

123 semantic_flat = _flatten(semantic) 

124 theme_flat = _flatten(theme_data) 

125 

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) 

133 

134 return DesignTokens( 

135 primitives=primitives, 

136 semantic=semantic, 

137 theme=theme_data, 

138 resolved=resolved, 

139 source_path=token_path, 

140 ) 

141 

142 

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) 

151 

152 

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)