mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
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:
parent
5045f20c40
commit
ccef40e817
33
.github/workflows/palette-drift.yml
vendored
Normal file
33
.github/workflows/palette-drift.yml
vendored
Normal 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
|
||||
231
unified-design-system/scripts/palette_check.py
Normal file
231
unified-design-system/scripts/palette_check.py
Normal 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())
|
||||
162
unified-design-system/scripts/palette_map.py
Normal file
162
unified-design-system/scripts/palette_map.py
Normal 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)$"
|
||||
)
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user