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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 15:27:17 +02:00
parent 5045f20c40
commit ccef40e817
4 changed files with 438 additions and 1 deletions

33
.github/workflows/palette-drift.yml vendored Normal file
View File

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

View File

@ -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 <root>/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())

View File

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

View File

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