From ccef40e8176b81d03def93162fa8300975ec1017 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 15:27:17 +0200 Subject: [PATCH] unified-design-system: check one palette across all four token sources `tokens.md` freezes the palette as prose and three packages re-express it as code -- `web_ui/src/tokens.css`, `design_system`'s `tokens.dart`, and gatelock's `LockConfig`. Nothing compared them, so "one palette everywhere" held only for as long as four hand-maintained copies happened to agree. A hex edited in one stack drifted silently in the other three. `palette_check.py` parses all four and runs two halves: * agreement -- every mapped token carries the same hex in every non-exempt stack * completeness -- every colour parsed from any source must be mapped or exempted with a stated reason The second half is the load-bearing one. A pure agreement check passes happily when someone adds a token to one stack and never touches the map, which is the drift most likely to actually happen; an unmapped token is therefore a failure, not a skip. Absences are declared per token per stack with a reason (Tk has no light theme and no borders; the categorical ramp is web-only) so "this stack doesn't have it" is on the record rather than a silent pass. It found one real gap on its first run: `tokens.css` sets `--surface-2` to `paper-raised` in the light block -- the light theme collapses both elevation steps onto one surface -- which `tokens.md` documented only in prose and no map covered. Now an explicitly compared row, so if either half of that pair moves, it fails. Proven to fail, not just to pass: drift injected into each of the four sources in turn, plus an unmapped token, was caught every time with the offending stack named; re-proven after the 250-line-cap split into `palette_map.py`. Runs from its own workflow with deliberately no `paths:` filter -- drift is a disagreement *between* packages, so a path-scoped trigger would skip exactly the edit that causes it. That was not hypothetical: `ramp_check.py` was reachable only from the `web_ui/**`-filtered workflow, so a ramp edit in `tokens.md` or `design_system` never ran it. It moves here too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno --- .github/workflows/palette-drift.yml | 33 +++ .../scripts/palette_check.py | 231 ++++++++++++++++++ unified-design-system/scripts/palette_map.py | 162 ++++++++++++ unified-design-system/tokens.md | 13 +- 4 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/palette-drift.yml create mode 100644 unified-design-system/scripts/palette_check.py create mode 100644 unified-design-system/scripts/palette_map.py diff --git a/.github/workflows/palette-drift.yml b/.github/workflows/palette-drift.yml new file mode 100644 index 0000000..b55845c --- /dev/null +++ b/.github/workflows/palette-drift.yml @@ -0,0 +1,33 @@ +name: palette drift + +# Deliberately NOT path-filtered. Every other workflow here scopes itself to +# one package, which is right for tests but wrong for this check: drift is a +# disagreement *between* packages, so editing design_system alone has to be +# able to fail it. A `paths:` filter would let exactly the edit that causes +# drift be the one that skips the check. +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + palette: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + + # Compares tokens.md (prose freeze), web_ui/src/tokens.css, + # design_system tokens.dart and gatelock's LockConfig. Fails on a hex + # that disagrees across stacks, and on a colour token that no stack's + # map accounts for. + - name: One palette across all four token sources + run: python3 unified-design-system/scripts/palette_check.py + + - name: Categorical ramp still passes its constraints + run: python3 unified-design-system/scripts/ramp_check.py diff --git a/unified-design-system/scripts/palette_check.py b/unified-design-system/scripts/palette_check.py new file mode 100644 index 0000000..c68ad95 --- /dev/null +++ b/unified-design-system/scripts/palette_check.py @@ -0,0 +1,231 @@ +"""Verify one palette across all four token sources. + +`tokens.md` freezes the palette as prose, and three packages re-express it as +code: `web_ui/src/tokens.css` (CSS custom properties), `design_system` +(Dart constants) and `gatelock` (a `LockConfig` dataclass). Nothing until now +compared them, so a hex edited in one stack drifted silently in the other +three -- the exact failure "one palette everywhere" is supposed to exclude. + +The check is deliberately bidirectional. Comparing only the tokens named in +the map below would pass silently the moment someone adds a token to one +source and forgets the map, which is the drift most likely to actually +happen. So: + + * every token parsed out of every source must be accounted for -- either + mapped, or named in `EXEMPT`; an unmapped token is a failure, not a skip + * every mapped token must resolve in every stack that is not exempt for it + +Exemptions are declared per token per stack, with a reason. A silent skip is +the fail-open path this script exists to close. + +Run: python3 unified-design-system/scripts/palette_check.py +Exit 0 = all sources agree and are complete; 1 = drift or an unmapped token. +""" + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + +from palette_map import NON_COLOUR_CSS, PALETTE, STACKS + +# Repo root: this file is /unified-design-system/scripts/palette_check.py +ROOT = Path(__file__).resolve().parents[2] + +TOKENS_MD = ROOT / "unified-design-system" / "tokens.md" +TOKENS_CSS = ROOT / "web_ui" / "src" / "tokens.css" +TOKENS_DART = ROOT / "design_system" / "lib" / "src" / "tokens.dart" +LOCKCONFIG_PY = ROOT / "gatelock" / "gatelock" / "_window.py" + + +@dataclass +class Result: + """One check outcome.""" + + ok: bool + detail: str + + +@dataclass +class Sources: + """Parsed colour tables, one per stack.""" + + md: dict[str, str] = field(default_factory=dict) + css: dict[str, str] = field(default_factory=dict) + dart: dict[str, str] = field(default_factory=dict) + tk: dict[str, str] = field(default_factory=dict) + + +def _norm(value: str) -> str: + """Normalise a hex colour to lowercase `#rrggbb` for comparison.""" + return "#" + value.lstrip("#").lower() + + +def parse_md(text: str) -> dict[str, str]: + """Pull `| \\`token\\` | \\`#HEX\\` |` rows out of the frozen prose table.""" + found: dict[str, str] = {} + row = re.compile(r"^\|\s*`([a-z0-9-]+)`\s*\|\s*`(#[0-9A-Fa-f]{6})`") + for line in text.splitlines(): + m = row.match(line.strip()) + if m: + found[m.group(1)] = _norm(m.group(2)) + return found + + +def parse_css(text: str) -> dict[str, str]: + """Parse `--token: #hex;`, tagging light-theme overrides with `@light`. + + The light palette lives in a `prefers-color-scheme: light` block that + redefines the same custom-property names, so the names alone collide. The + `@light` suffix keeps both halves addressable from one map. + """ + found: dict[str, str] = {} + # Split off the light-theme block; everything before it is the dark root. + light_at = text.find("prefers-color-scheme: light") + dark_text = text[:light_at] if light_at != -1 else text + light_text = "" + if light_at != -1: + rest = text[light_at:] + # The light block ends at the next top-level @media. + nxt = rest.find("@media", 1) + light_text = rest[:nxt] if nxt != -1 else rest + + decl = re.compile(r"(--[a-z0-9-]+)\s*:\s*(#[0-9A-Fa-f]{6})\s*;") + for name, value in decl.findall(dark_text): + found[name] = _norm(value) + for name, value in decl.findall(light_text): + found[f"{name}@light"] = _norm(value) + return found + + +def parse_dart(text: str) -> dict[str, str]: + """Parse `static const Color name = Color(0xFFRRGGBB);` and aliases.""" + found: dict[str, str] = {} + literal = re.compile( + r"static const Color (\w+) = Color\(0x[fF]{2}([0-9A-Fa-f]{6})\)" + ) + for name, value in literal.findall(text): + found[name] = _norm(value) + # Aliases: `static const Color info = accent;` + alias = re.compile(r"static const Color (\w+) = (\w+);") + for name, target in alias.findall(text): + if target in found: + found[name] = found[target] + return found + + +def parse_tk(text: str) -> dict[str, str]: + """Parse `field: str = "#RRGGBB"` defaults out of the LockConfig block.""" + found: dict[str, str] = {} + start = text.find("class LockConfig") + if start == -1: + return found + body = text[start:] + decl = re.compile(r'^\s{4}(\w+):\s*str\s*=\s*"(#[0-9A-Fa-f]{6})"', re.MULTILINE) + for name, value in decl.findall(body): + found[name] = _norm(value) + return found + + +def load() -> Sources: + """Read and parse all four token sources.""" + return Sources( + md=parse_md(TOKENS_MD.read_text(encoding="utf-8")), + css=parse_css(TOKENS_CSS.read_text(encoding="utf-8")), + dart=parse_dart(TOKENS_DART.read_text(encoding="utf-8")), + tk=parse_tk(LOCKCONFIG_PY.read_text(encoding="utf-8")), + ) + + +def check_agreement(src: Sources) -> list[Result]: + """Every mapped token must carry the same hex in every non-exempt stack.""" + results: list[Result] = [] + for token in PALETTE: + seen: dict[str, str] = {} + missing: list[str] = [] + for stack in STACKS: + name = getattr(token, stack) + if name is None: + continue # declared absence, justified in `why` + table: dict[str, str] = getattr(src, stack) + if name not in table: + missing.append(f"{stack}:{name}") + else: + seen[stack] = table[name] + + if missing: + results.append( + Result(False, f"{token.canonical:<14} MISSING {', '.join(missing)}") + ) + continue + + values = set(seen.values()) + if len(values) > 1: + detail = ", ".join(f"{s}={v}" for s, v in sorted(seen.items())) + results.append(Result(False, f"{token.canonical:<14} DRIFT {detail}")) + else: + only = next(iter(values)) + results.append( + Result( + True, + f"{token.canonical:<14} {only} across {len(seen)} stack(s)", + ) + ) + return results + + +def check_completeness(src: Sources) -> list[Result]: + """Every colour a source defines must be mapped -- no silent extras. + + This is the half that catches "someone added a token and never touched + the map", which a pure agreement check passes happily. + """ + results: list[Result] = [] + for stack in STACKS: + mapped = {getattr(t, stack) for t in PALETTE if getattr(t, stack) is not None} + table: dict[str, str] = getattr(src, stack) + extras = sorted( + name + for name in table + if name not in mapped and not NON_COLOUR_CSS.match(name) + ) + if extras: + results.append( + Result( + False, + f"{stack:<4} defines unmapped colour(s): {', '.join(extras)} " + "-- add them to PALETTE (or declare an exemption)", + ) + ) + else: + results.append( + Result( + True, f"{stack:<4} all {len(table)} parsed token(s) accounted for" + ) + ) + return results + + +def main() -> int: + """Run both halves of the check and report.""" + src = load() + + print("== agreement: one hex per token, across stacks ==") + results = check_agreement(src) + for r in results: + print(("PASS " if r.ok else "FAIL ") + r.detail) + + print("\n== completeness: no unmapped colour in any source ==") + completeness = check_completeness(src) + for r in completeness: + print(("PASS " if r.ok else "FAIL ") + r.detail) + + failures = sum(not r.ok for r in (*results, *completeness)) + print(f"\n{failures} failing check(s)") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/unified-design-system/scripts/palette_map.py b/unified-design-system/scripts/palette_map.py new file mode 100644 index 0000000..f04378c --- /dev/null +++ b/unified-design-system/scripts/palette_map.py @@ -0,0 +1,162 @@ +"""The canonical palette table: one row per colour, named in each stack. + +Split out of `palette_check.py` to hold both files under the 250-line cap. +This module is pure data -- the parsing and comparison logic lives in the +checker. Adding a token to any stack means adding a row here; the checker's +completeness half fails on a token no row accounts for, which is what stops +this table from quietly falling behind the sources it describes. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +STACKS = ("md", "css", "dart", "tk") + + +@dataclass(frozen=True) +class Token: + """One canonical colour token and its name in each stack. + + A `None` name means the token is intentionally absent from that stack; + `why` records the reason so an absence is a decision on the record rather + than an oversight. + """ + + canonical: str + md: str | None + css: str | None + dart: str | None + tk: str | None + why: str = "" + + +# The canonical palette: one row per colour, named per stack. +# +# Deliberate absences (the `None`s) and their reasons: +# * paper/paper-raised/line-light/text-on-light/muted-on-light -- the light +# theme. CSS carries it in a `prefers-color-scheme` block (parsed +# separately below); Dart names them as constants; Tk has no light theme +# at all, because a locker surface is always dark. +# * cat-1..6 -- the categorical ramp shipped for web only. Dart and Tk have +# no chart/tag surfaces yet. Adding one means adding it here too. +# * on-scrim -- introduced by the web layer for translucent overlays; the +# other two stacks have no scrim surface. +PALETTE: tuple[Token, ...] = ( + # --- neutrals, dark (all three code stacks) --------------------------- + Token("ink", "ink", "--bg", "ink", "bg"), + Token("ink-raised-1", "ink-raised-1", "--surface-1", "inkRaised1", "field_bg"), + Token( + "ink-raised-2", + "ink-raised-2", + "--surface-2", + "inkRaised2", + None, + why="Tk gates use a single raised step (field_bg); no second elevation.", + ), + Token( + "line-dark", + "line-dark", + "--border", + "lineDark", + None, + why="Tk gates draw no borders; elevation is a fill step.", + ), + Token("text-on-dark", "text-on-dark", "--text", "textOnDark", "fg"), + Token("muted-on-dark", "muted-on-dark", "--text-muted", "mutedOnDark", "muted"), + # --- neutrals, light -------------------------------------------------- + Token( + "paper", + "paper", + "--bg@light", + "paper", + None, + why="Tk: locker surfaces are always dark; there is no light theme.", + ), + Token( + "paper-raised", + "paper-raised", + "--surface-1@light", + "paperRaised", + None, + why="Tk: no light theme.", + ), + # The light theme collapses both dark elevation steps onto one raised + # surface: `--surface-2` is redefined to `paper-raised` rather than + # getting a value of its own. Mapped explicitly (not exempted) so it is + # still compared -- if either half of the pair moves, this row fails. + Token( + "paper-raised-2", + "paper-raised", + "--surface-2@light", + "paperRaised", + None, + why="Light theme has one raised step; Tk has no light theme.", + ), + Token( + "line-light", + "line-light", + "--border@light", + "lineLight", + None, + why="Tk: no light theme, and no borders.", + ), + Token( + "text-on-light", + "text-on-light", + "--text@light", + "textOnLight", + None, + why="Tk: no light theme.", + ), + Token( + "muted-on-light", + "muted-on-light", + "--text-muted@light", + "mutedOnLight", + None, + why="Tk: no light theme.", + ), + # --- accent + semantic roles (all three) ------------------------------- + Token("accent", "accent", "--accent", "accent", "accent"), + Token( + "info", + "info", + "--info", + "info", + None, + why="Tk: no informational role distinct from accent in the gates.", + ), + Token("success", "success", "--success", "success", "success"), + Token("warning", "warning", "--warning", "warning", "warning"), + Token("danger", "danger", "--danger", "danger", "danger"), + Token("on-fill", "on-fill", "--on-fill", "onFill", "on_fill"), + Token("focus-ring", "focus-ring", "--focus-ring", None, "focus_ring"), + Token( + "on-scrim", + None, + "--on-scrim", + None, + None, + why="Web-only: no scrim/overlay surface in the Flutter or Tk layers.", + ), + # --- categorical ramp (web only, for now) ------------------------------ + *( + Token( + f"cat-{i}", + f"cat-{i}", + f"--cat-{i}", + None, + None, + why="Ramp shipped for web only; no chart/tag surface in Dart or Tk yet.", + ) + for i in range(1, 7) + ), +) + +# Tokens a source legitimately defines that are not colours in the palette -- +# structural/typographic values checked by the scale check, not the hex check. +NON_COLOUR_CSS = re.compile( + r"^--(radius|space|text|tracking|font|measure|shadow)-|^--(font-sans|font-mono|measure)$" +) diff --git a/unified-design-system/tokens.md b/unified-design-system/tokens.md index 5513971..1025084 100644 --- a/unified-design-system/tokens.md +++ b/unified-design-system/tokens.md @@ -3,7 +3,18 @@ Concrete instantiation of `safe-design-rules`' 28 rules. One identical palette across every repo/stack (Flutter, web, Python/Tkinter) — not just shared rules, the same hex values everywhere, including Flutter launcher-icon -glyphs (`app-icon` skill). Anchored on the shared icon charcoal `#211D1B` so +glyphs (`app-icon` skill). + +> **This table is checked, not trusted.** +> `scripts/palette_check.py` parses all four token sources — this file, +> `web_ui/src/tokens.css`, `design_system/lib/src/tokens.dart` and gatelock's +> `LockConfig` — and fails on any hex that disagrees across them, *including a +> hex edited only here*. It also fails on a colour token that no stack's map +> accounts for, so adding a token to one stack and forgetting the others is a +> CI failure rather than silent drift. Runs in `.github/workflows/palette-drift.yml`, +> deliberately with no `paths:` filter: drift is a disagreement *between* +> packages, so a path-scoped trigger would skip exactly the edit that causes it. +> Editing any value below means editing it in all four places. Anchored on the shared icon charcoal `#211D1B` so the UI palette and the icon family cohere instead of adding a second "near-black." Warm-tinted throughout (rule 10) — superseded from an earlier cool-tinted draft; every neutral is a byte-reversed (RRGGBB→BBGGRR) mirror of