utils/unified-design-system/scripts/palette_map.py
Krzysztof kuhy Rudnicki ccef40e817 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
2026-08-15 15:27:17 +02:00

163 lines
5.2 KiB
Python

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