1"""ll-verify-design-tokens: Structural lint for half-flipped design-token themes.

2

3 A design-token profile pairs a light-tuned ``semantic.json`` (defining the

4``surface``, ``text``, ``border`` and ``action`` color groups) with one or more

5``themes/<name>.json`` overrides. A *half-flipped* theme is one that inverts the

6 foreground/background pair — overriding both ``surface`` **and** ``text`` to move

7 onto a near-black (or near-white) surface — but leaves ``border``/``action`` (and

8 any other semantic color group) falling through to the light-tuned defaults. The

9 token-stamping path then renders those light values onto the inverted surface:

10 harsh gridlines, muddy accents, and a ``danger == action.primary`` collision.

11

12 This lint catches that class at authoring time. For every profile under the

13 profiles directory it computes, for each theme that performs a full inversion

14(overrides ``surface`` and ``text``), the set of semantic color groups the theme

15 fails to override. A non-empty set is a violation.

16

17 A theme that does not invert the surface/text pair (e.g. a ``light.json`` that

18 only restates ``surface``) is exempt — falling through to the light-tuned

19``semantic.json`` is correct for it.

20

21 Exit 0 when every inverting theme is complete; exit 1 on any violation.

22"""

23

24 from __future__ import annotations

25

26 import argparse

27 import json

28 import sys

29 from dataclasses import dataclass, field

30 from pathlib import Path

31

32 from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context

33

34# ---------------------------------------------------------------------------

35# Constants

36# ---------------------------------------------------------------------------

37

38# Theme is treated as a full foreground/background inversion (and therefore

39# required to override every semantic color group) only when it overrides BOTH

40# of these groups. A theme touching neither (or only one) is not inverting and

41# is exempt from the completeness requirement.

42_INVERSION_GROUPS: frozenset[str] = frozenset({"surface", "text"})

43

44# ---------------------------------------------------------------------------

45# Data types

46# ---------------------------------------------------------------------------

47

48

49@dataclass

50 class ThemeViolation:

51 """One half-flipped theme: an inverting theme missing semantic color groups."""

52

53 profile: str

54 theme: str

55 missing_groups: list[str]

56

57

58@dataclass

59 class ProfileResult:

60 """Lint result for a single profile directory."""

61

62 profile: str

63 violations: list[ThemeViolation] = field(default_factory=list)

64

65 @property

66 def has_violations(self) -> bool:

67 return bool(self.violations)

68

69

70# ---------------------------------------------------------------------------

71# Core lint logic

72# ---------------------------------------------------------------------------

73

74

75 def _load_json(path: Path) -> dict:

76 """Load a JSON object, returning {} on absence or parse/type error."""

77 if not path.exists():

78 return {}

79 try:

80 data = json.loads(path.read_text(encoding="utf-8"))

81 except (OSError, json.JSONDecodeError):

82 return {}

83 return data if isinstance(data, dict) else {}

84

85

86 def _color_groups(doc: dict) -> set[str]:

87 """Return the set of color-group keys (surface, text, border, action, ...)."""

88 color = doc.get("color")

89 return set(color.keys()) if isinstance(color, dict) else set()

90

91

92 def lint_profile(profile_dir: Path) -> ProfileResult:

93 """Lint one profile directory for half-flipped themes.

94

95 For each ``themes/*.json`` that overrides both ``surface`` and ``text``

96 (a full inversion), report any semantic color group it fails to override.

97 """

98 result = ProfileResult(profile=profile_dir.name)

99

100 semantic_groups = _color_groups(_load_json(profile_dir / "semantic.json"))

101 if not semantic_groups:

102 return result # nothing to compare against

103

104 themes_dir = profile_dir / "themes"

105 if not themes_dir.is_dir():

106 return result

107

108 for theme_file in sorted(themes_dir.glob("*.json")):

109 theme_groups = _color_groups(_load_json(theme_file))

110 # Only inverting themes (override both surface and text) must be complete.

111 if not _INVERSION_GROUPS.issubset(theme_groups):

112 continue

113 missing = semantic_groups - theme_groups

114 if missing:

115 result.violations.append(

116 ThemeViolation(

117 profile=profile_dir.name,

118 theme=theme_file.stem,

119 missing_groups=sorted(missing),

120 )

121 )

122

123 return result

124

125

126 def lint_profiles_dir(profiles_dir: Path) -> list[ProfileResult]:

127 """Lint every profile subdirectory under *profiles_dir*."""

128 results: list[ProfileResult] = []

129 for profile_dir in sorted(p for p in profiles_dir.iterdir() if p.is_dir()):

130 result = lint_profile(profile_dir)

131 if result.has_violations:

132 results.append(result)

133 return results

134

135

136# ---------------------------------------------------------------------------

137# Reporting

138# ---------------------------------------------------------------------------

139

140

141 def _format_text_report(results: list[ProfileResult]) -> str:

142 lines: list[str] = ["Design-Token Theme Completeness Lint", "=" * 50, ""]

143 if results:

144 lines.append("Half-flipped themes (override surface+text but omit groups):")

145 for r in results:

146 for v in r.violations:

147 groups = ", ".join(v.missing_groups)

148 lines.append(f" {v.profile}/themes/{v.theme}.json: missing color groups: {groups}")

149 lines.append("")

150 lines.append("FAILED")

151 else:

152 lines.append("All inverting themes override every semantic color group.")

153 lines.append("")

154 lines.append("PASSED")

155 return "\n".join(lines)

156

157

158 def _format_json_report(results: list[ProfileResult]) -> str:

159 return json.dumps(

160 {

161 "violations": [

162 {

163 "profile": v.profile,

164 "theme": v.theme,

165 "missing_groups": v.missing_groups,

166 }

167 for r in results

168 for v in r.violations

169 ],

170 "passed": not results,

171 },

172 indent=2,

173 )

174

175

176# ---------------------------------------------------------------------------

177# Entry point

178# ---------------------------------------------------------------------------

179

180

181 def _find_profiles_dir(base_dir: Path) -> Path | None:

182 """Locate a design-token ``profiles/`` directory under *base_dir*.

183

184 Tries the source-repo bundled-template layout (editable + installed) and the

185 user-project ``.ll/design-tokens/profiles/`` layout.

186 """

187 for candidate in (

188 base_dir / "scripts" / "little_loops" / "templates" / "design-tokens" / "profiles",

189 base_dir / "little_loops" / "templates" / "design-tokens" / "profiles",

190 base_dir / ".ll" / "design-tokens" / "profiles",

191 ):

192 if candidate.is_dir():

193 return candidate

194 return None

195

196

197 def main_verify_design_tokens() -> int:

198 """Entry point for ll-verify-design-tokens.

199

200 Returns 0 when no half-flipped themes are found; 1 otherwise.

201

202 Note: run against the bundled little-loops templates this currently flags

203 ``editorial-mono`` (a known-incomplete profile pending a follow-on); fix or

204 point ``--profiles-dir`` at a complete profile set to gate CI on exit 0.

205 """

206 with cli_event_context(DEFAULT_DB_PATH, "ll-verify-design-tokens", sys.argv[1:]):

207 parser = argparse.ArgumentParser(

208 prog="ll-verify-design-tokens",

209 description=(

210 "Lint design-token profiles for half-flipped themes — a theme that "

211 "inverts surface+text but leaves border/action falling through to "

212 "light-tuned semantic defaults."

213 ),

214 formatter_class=argparse.RawDescriptionHelpFormatter,

215 epilog="""\

216 Examples:

217 %(prog)s # Auto-discover profiles dir from cwd

218 %(prog)s -C /path/to/root # Discover under a specific project root

219 %(prog)s --profiles-dir DIR # Lint a specific profiles directory

220 %(prog)s --json # Machine-readable JSON output

221

222 Exit codes:

223 0 - Every inverting theme overrides all semantic color groups

224 1 - One or more half-flipped themes (or no profiles dir found)

225""",

226 )

227 parser.add_argument(

228 "-C",

229 "--directory",

230 type=Path,

231 default=None,

232 help="Project root to discover the profiles directory under (default: cwd)",

233 )

234 parser.add_argument(

235 "--profiles-dir",

236 type=Path,

237 default=None,

238 help="Explicit path to a design-token profiles/ directory (overrides -C discovery)",

239 )

240 parser.add_argument(

241 "--json",

242 action="store_true",

243 default=False,

244 help="Output results as JSON",

245 )

246

247 args = parser.parse_args()

248

249 profiles_dir = args.profiles_dir or _find_profiles_dir(args.directory or Path.cwd())

250

251 if profiles_dir is None or not profiles_dir.is_dir():

252 print(

253 "ERROR: design-token profiles directory not found "

254 f"(searched under {args.directory or Path.cwd()})",

255 file=sys.stderr,

256 )

257 return 1

258

259 results = lint_profiles_dir(profiles_dir)

260

261 if args.json:

262 print(_format_json_report(results))

263 else:

264 print(_format_text_report(results))

265

266 return 1 if results else 0