From cbcee98d94fb2c8fbabef1d9a712a7ca93e5b5bb Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 08:18:27 +0100 Subject: [PATCH] Add structural_check: spacing/radius/type/motion drift Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/palette-drift.yml | 5 + dopamine-ux/TODO-02-structural-check.md | 139 ------------- .../TODO-dopamine-ux-02-structural-check.md | 177 ----------------- unified-design-system/scripts/palette_map.py | 2 +- .../scripts/structural_check.py | 138 +++++++++++++ .../scripts/structural_map.py | 87 +++++++++ .../scripts/structural_parse.py | 110 +++++++++++ .../tests/test_structural_check.py | 182 ++++++++++++++++++ 8 files changed, 523 insertions(+), 317 deletions(-) delete mode 100644 dopamine-ux/TODO-02-structural-check.md delete mode 100644 prompts/TODO-dopamine-ux-02-structural-check.md create mode 100644 unified-design-system/scripts/structural_check.py create mode 100644 unified-design-system/scripts/structural_map.py create mode 100644 unified-design-system/scripts/structural_parse.py create mode 100644 unified-design-system/tests/test_structural_check.py diff --git a/.github/workflows/palette-drift.yml b/.github/workflows/palette-drift.yml index e736d1f..acce2f0 100644 --- a/.github/workflows/palette-drift.yml +++ b/.github/workflows/palette-drift.yml @@ -31,3 +31,8 @@ jobs: - name: Categorical ramp still passes its constraints run: python3 unified-design-system/scripts/ramp_check.py + + # Spacing, radius, type, shadow and motion: palette_check.py parses + # hex only, so these scales were unguarded until structural_check.py. + - name: Structural scales agree across all token sources + run: python3 unified-design-system/scripts/structural_check.py diff --git a/dopamine-ux/TODO-02-structural-check.md b/dopamine-ux/TODO-02-structural-check.md deleted file mode 100644 index fde3628..0000000 --- a/dopamine-ux/TODO-02-structural-check.md +++ /dev/null @@ -1,139 +0,0 @@ -Add a structural-token drift checker to CI - -## findings (2, ranked by value over effort) - -1. **Spacing, radius and type have never been drift-checked** — `palette_check.py` - parses hex only, so three whole token categories can diverge across stacks - while CI stays green. Motion inherits the same hole. -2. **`palette_map.py` points at a "scale check" that does not exist** — a - comment describing a guarantee nobody wrote. - -## what - -`palette_check.py` guarantees the colour palette agrees across all stack copies. -It does **not** check anything else — verified 2026-08-16 by reading its four -parsers, every one of which requires a `#RRGGBB` value: - -```python -parse_css : r"(--[a-z0-9-]+)\s*:\s*(#[0-9A-Fa-f]{6})\s*;" -parse_dart: r"static const Color (\w+) = Color\(0x[fF]{2}([0-9A-Fa-f]{6})\)" -parse_tk : r'^\s{4}(\w+):\s*str\s*=\s*"(#[0-9A-Fa-f]{6})"' -parse_md : r"^\|\s*`([a-z0-9-]+)`\s*\|\s*`(#[0-9A-Fa-f]{6})`" -``` - -So **spacing, radius and type have been silently unprotected all along**, and the -motion tokens added by prompt 01 inherit the same hole. A `--duration-fast: 120ms` -is invisible to every parser: CI passes while the stacks drift apart. - -Worse, `scripts/palette_map.py:159` claims structural values are "checked by the -scale check" — **no such script exists anywhere in `~/src/utils`**. That comment -describes a check that was never written. This prompt writes it. - -## where - -Repo: `~/src/utils`. - -**Create** `~/src/utils/unified-design-system/scripts/structural_check.py`. - -**Edit** `~/src/utils/.github/workflows/palette-drift.yml` — add a step invoking it. -The workflow deliberately has no `paths:` filter; keep it that way. - -**Fix** the stale comment at `~/src/utils/unified-design-system/scripts/palette_map.py` -(~:159) to name the real script. - -Sources to compare (three code stacks + the prose freeze): - -| Role | Path | -|---|---| -| prose freeze | `~/src/utils/unified-design-system/tokens.md` (spacing/radius/type) | -| motion freeze | `~/src/utils/unified-design-system/motion.md` (**created by prompt 01** — run it first) | -| web/React | `~/src/utils/web_ui/src/tokens.css` | -| Flutter/Dart | `~/src/utils/design_system/lib/src/tokens.dart` | -| Python/Tkinter | `~/src/utils/gatelock/gatelock/_window.py` (`class LockConfig`) | - -## must - -- Cover **motion** (durations, easing) **and** the already-unprotected - **spacing, radius and type** scales, in one checker. -- Mirror `palette_check.py`'s two-halves design, because it is the part that - actually works: - 1. **Agreement** — for each mapped token, resolve it in every non-`n/a` stack - and fail if values disagree. - 2. **Completeness, bidirectional** — every structural value parsed out of any - source must be mapped, or explicitly exempted. This is what catches - "added it to one stack, forgot the others". -- Support an explicit **`n/a` per stack**, and require a reason string. Tkinter - has no animation framework; a duration legitimately does not exist there. An - honest `n/a` with a reason is correct — a fabricated constant is not. -- **Adjudicate with exit codes.** Exit non-zero on drift with a message naming - the token and the disagreeing files. No warnings-only mode. -- Keep every file ≤ **250 lines** (`~/src/utils/file_length/config.py`). Note - `palette_check.py` is 231 and `palette_map.py` is 162 — that pair was split - precisely to stay under the cap. **Split yours the same way from the start** - (`structural_check.py` + `structural_map.py`) rather than discovering the - problem at 251 lines. -- Ruff-clean under the repo's config. No `# noqa`, no `type: ignore`. - -- **Expect it to fail on first run.** Spacing/radius/type have never been - compared; there may be real pre-existing drift. That is the point of writing - it. Fix whatever it surfaces, in the stack copy that is wrong, and say in the - session summary what drifted. -- must not: weaken the checker, add an exemption, or narrow its scope to make it - green. If a token genuinely differs per stack for a good reason, encode it as - an `n/a`/variant **with the reason recorded**, not as a silent skip. -- must not: modify `palette_check.py`. It works; it is simply colour-only. The - two scripts run side by side. -- must not: change any token *value* to resolve a disagreement without saying so - explicitly — that is a design change, and `tokens.md` is a freeze. Surface it - and let kuhy decide which value is canonical. - -- optional: teach it the shadow tier too, if that falls out cheaply. - -## done - -1. `python3 ~/src/utils/unified-design-system/scripts/structural_check.py` exits 0, - and exits non-zero when you deliberately break one stack copy (**test both - directions** — a checker that never fails is not a checker). -2. It is wired into `~/src/utils/.github/workflows/palette-drift.yml` and the - workflow passes. -3. `palette_map.py`'s "scale check" comment names the real script. -4. `~/src/utils/scripts/check_file_length.sh --all` reports no new violations. -5. The session summary states what drift, if any, the first run found. - -## verify - -Desktop. Run the script, paste the output. Then prove it fails: change one -spacing or duration value in **one** stack copy, re-run, paste the non-zero exit -and the message, and revert. Both halves of that demonstration are required — -the passing run alone proves nothing. - -## read first - -- `~/src/utils/unified-design-system/scripts/palette_check.py` — the design to - mirror. Read `NON_COLOUR_CSS`: it is the regex that currently *excludes* - spacing/radius/type/shadow from the colour check, and therefore the precise - list of what you are now covering. -- `~/src/utils/unified-design-system/scripts/palette_map.py` — the `PALETTE` tuple of - `Token(canonical, md, css, dart, tk, why)` rows. Your map wants the same shape, - including the `why` field. -- `~/src/utils/.github/workflows/palette-drift.yml` — how `palette_check.py` and - `ramp_check.py` are invoked; add your step alongside. -- `~/src/utils/unified-design-system/motion.md` — what prompt 01 created. -- `~/src/utils/unified-design-system/tokens.md` — the spacing/radius/type tables. - **Do not edit it**; it is 260 lines and already over the cap. - -## context you would otherwise rediscover - -- The four "stack copies" are really **three code stacks plus a prose freeze**. - There is no fourth code stack; do not hunt for one. -- `~/src/dufs-cloud/app/lib/ui/theme.dart` is a **fifth, uncompared copy** of the - palette (the Flutter app has no `design_system` dependency and hand-restates - every hex). It is out of scope here — prompt 07 wires it up properly. Do not - add it as a source to this checker; wait until 07 has removed it. -- Three Flutter apps (diet-guard, workout_app, wake_alarm) similarly - hand-transcribe tokens into local `lib/ui/theme.dart` files. Those are - deliberately outside the checker's scope for now — bringing them in is a much - larger job than this prompt. -- `~/src/utils/gatelock/` is a package inside this monorepo, not a separate repo. - -REMOVE ME AFTER FINISH diff --git a/prompts/TODO-dopamine-ux-02-structural-check.md b/prompts/TODO-dopamine-ux-02-structural-check.md deleted file mode 100644 index 7b44839..0000000 --- a/prompts/TODO-dopamine-ux-02-structural-check.md +++ /dev/null @@ -1,177 +0,0 @@ -> **This file is a ready-to-use prompt.** Open this repo and say -> "do dopamine-ux-02-structural-check". It is self-contained -- it needs no context from -> any other session. -> -> Canonical copy: `~/src/utils/dopamine-ux/02-structural-check.md` (source of truth; this is a -> distributed copy). Running order and cross-prompt rules: `~/src/utils/dopamine-ux/00-INDEX.md`. -> Generated 2026-08-16 from a survey of this repo -- line numbers are accurate -> as of that date; **anchor on the symbol names, not the line numbers.** - -## ⛔ WHEN YOU FINISH THIS PROMPT - -Do **both**, in the same session that completes the work: - -1. **Delete this file** and commit that deletion together with the - implementation: - ```bash - git rm prompts/dopamine-ux-02-structural-check.md - ``` - Do not leave a finished prompt lying in the repo -- a stale prompt is - indistinguishable from a pending one, and the next session will re-run it. - -2. **Log completion in the canonical index**, in `~/src/utils` (a *different* repo, - so it needs its own commit): - ```bash - cd ~/src/utils - # append to the "Completion log" table in dopamine-ux/00-INDEX.md: - # | 02-structural-check.md | DONE | | | - git add dopamine-ux/00-INDEX.md - git commit -m "dopamine-ux: mark 02-structural-check.md done" - git push - ``` - -If you finish only part of the prompt, do **neither** -- instead edit this file -to strike out what is done and state what remains, so the next session starts -from the true state. Partial completion must never be recorded as DONE. - ---- - -Add a structural-token drift checker to CI - -## findings (2, ranked by value over effort) - -1. **Spacing, radius and type have never been drift-checked** — `palette_check.py` - parses hex only, so three whole token categories can diverge across stacks - while CI stays green. Motion inherits the same hole. -2. **`palette_map.py` points at a "scale check" that does not exist** — a - comment describing a guarantee nobody wrote. - -## what - -`palette_check.py` guarantees the colour palette agrees across all stack copies. -It does **not** check anything else — verified 2026-08-16 by reading its four -parsers, every one of which requires a `#RRGGBB` value: - -```python -parse_css : r"(--[a-z0-9-]+)\s*:\s*(#[0-9A-Fa-f]{6})\s*;" -parse_dart: r"static const Color (\w+) = Color\(0x[fF]{2}([0-9A-Fa-f]{6})\)" -parse_tk : r'^\s{4}(\w+):\s*str\s*=\s*"(#[0-9A-Fa-f]{6})"' -parse_md : r"^\|\s*`([a-z0-9-]+)`\s*\|\s*`(#[0-9A-Fa-f]{6})`" -``` - -So **spacing, radius and type have been silently unprotected all along**, and the -motion tokens added by prompt 01 inherit the same hole. A `--duration-fast: 120ms` -is invisible to every parser: CI passes while the stacks drift apart. - -Worse, `scripts/palette_map.py:159` claims structural values are "checked by the -scale check" — **no such script exists anywhere in `~/src/utils`**. That comment -describes a check that was never written. This prompt writes it. - -## where - -Repo: `~/src/utils`. - -**Create** `~/src/utils/unified-design-system/scripts/structural_check.py`. - -**Edit** `~/src/utils/.github/workflows/palette-drift.yml` — add a step invoking it. -The workflow deliberately has no `paths:` filter; keep it that way. - -**Fix** the stale comment at `~/src/utils/unified-design-system/scripts/palette_map.py` -(~:159) to name the real script. - -Sources to compare (three code stacks + the prose freeze): - -| Role | Path | -|---|---| -| prose freeze | `~/src/utils/unified-design-system/tokens.md` (spacing/radius/type) | -| motion freeze | `~/src/utils/unified-design-system/motion.md` (**created by prompt 01** — run it first) | -| web/React | `~/src/utils/web_ui/src/tokens.css` | -| Flutter/Dart | `~/src/utils/design_system/lib/src/tokens.dart` | -| Python/Tkinter | `~/src/utils/gatelock/gatelock/_window.py` (`class LockConfig`) | - -## must - -- Cover **motion** (durations, easing) **and** the already-unprotected - **spacing, radius and type** scales, in one checker. -- Mirror `palette_check.py`'s two-halves design, because it is the part that - actually works: - 1. **Agreement** — for each mapped token, resolve it in every non-`n/a` stack - and fail if values disagree. - 2. **Completeness, bidirectional** — every structural value parsed out of any - source must be mapped, or explicitly exempted. This is what catches - "added it to one stack, forgot the others". -- Support an explicit **`n/a` per stack**, and require a reason string. Tkinter - has no animation framework; a duration legitimately does not exist there. An - honest `n/a` with a reason is correct — a fabricated constant is not. -- **Adjudicate with exit codes.** Exit non-zero on drift with a message naming - the token and the disagreeing files. No warnings-only mode. -- Keep every file ≤ **250 lines** (`~/src/utils/file_length/config.py`). Note - `palette_check.py` is 231 and `palette_map.py` is 162 — that pair was split - precisely to stay under the cap. **Split yours the same way from the start** - (`structural_check.py` + `structural_map.py`) rather than discovering the - problem at 251 lines. -- Ruff-clean under the repo's config. No `# noqa`, no `type: ignore`. - -- **Expect it to fail on first run.** Spacing/radius/type have never been - compared; there may be real pre-existing drift. That is the point of writing - it. Fix whatever it surfaces, in the stack copy that is wrong, and say in the - session summary what drifted. -- must not: weaken the checker, add an exemption, or narrow its scope to make it - green. If a token genuinely differs per stack for a good reason, encode it as - an `n/a`/variant **with the reason recorded**, not as a silent skip. -- must not: modify `palette_check.py`. It works; it is simply colour-only. The - two scripts run side by side. -- must not: change any token *value* to resolve a disagreement without saying so - explicitly — that is a design change, and `tokens.md` is a freeze. Surface it - and let kuhy decide which value is canonical. - -- optional: teach it the shadow tier too, if that falls out cheaply. - -## done - -1. `python3 ~/src/utils/unified-design-system/scripts/structural_check.py` exits 0, - and exits non-zero when you deliberately break one stack copy (**test both - directions** — a checker that never fails is not a checker). -2. It is wired into `~/src/utils/.github/workflows/palette-drift.yml` and the - workflow passes. -3. `palette_map.py`'s "scale check" comment names the real script. -4. `~/src/utils/scripts/check_file_length.sh --all` reports no new violations. -5. The session summary states what drift, if any, the first run found. - -## verify - -Desktop. Run the script, paste the output. Then prove it fails: change one -spacing or duration value in **one** stack copy, re-run, paste the non-zero exit -and the message, and revert. Both halves of that demonstration are required — -the passing run alone proves nothing. - -## read first - -- `~/src/utils/unified-design-system/scripts/palette_check.py` — the design to - mirror. Read `NON_COLOUR_CSS`: it is the regex that currently *excludes* - spacing/radius/type/shadow from the colour check, and therefore the precise - list of what you are now covering. -- `~/src/utils/unified-design-system/scripts/palette_map.py` — the `PALETTE` tuple of - `Token(canonical, md, css, dart, tk, why)` rows. Your map wants the same shape, - including the `why` field. -- `~/src/utils/.github/workflows/palette-drift.yml` — how `palette_check.py` and - `ramp_check.py` are invoked; add your step alongside. -- `~/src/utils/unified-design-system/motion.md` — what prompt 01 created. -- `~/src/utils/unified-design-system/tokens.md` — the spacing/radius/type tables. - **Do not edit it**; it is 260 lines and already over the cap. - -## context you would otherwise rediscover - -- The four "stack copies" are really **three code stacks plus a prose freeze**. - There is no fourth code stack; do not hunt for one. -- `~/src/dufs-cloud/app/lib/ui/theme.dart` is a **fifth, uncompared copy** of the - palette (the Flutter app has no `design_system` dependency and hand-restates - every hex). It is out of scope here — prompt 07 wires it up properly. Do not - add it as a source to this checker; wait until 07 has removed it. -- Three Flutter apps (diet-guard, workout_app, wake_alarm) similarly - hand-transcribe tokens into local `lib/ui/theme.dart` files. Those are - deliberately outside the checker's scope for now — bringing them in is a much - larger job than this prompt. -- `~/src/utils/gatelock/` is a package inside this monorepo, not a separate repo. - -REMOVE ME AFTER FINISH diff --git a/unified-design-system/scripts/palette_map.py b/unified-design-system/scripts/palette_map.py index f04378c..71774ea 100644 --- a/unified-design-system/scripts/palette_map.py +++ b/unified-design-system/scripts/palette_map.py @@ -156,7 +156,7 @@ PALETTE: tuple[Token, ...] = ( ) # Tokens a source legitimately defines that are not colours in the palette -- -# structural/typographic values checked by the scale check, not the hex check. +# structural/typographic values checked by structural_check.py, 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/scripts/structural_check.py b/unified-design-system/scripts/structural_check.py new file mode 100644 index 0000000..5d0d0f7 --- /dev/null +++ b/unified-design-system/scripts/structural_check.py @@ -0,0 +1,138 @@ +"""Verify spacing, radius, type, shadow and motion agree across all stacks. + +`palette_check.py` guards colour only (every parser wants `#RRGGBB`), so these +scales were unprotected until now. Same two halves: + + * agreement -- each mapped token resolves in every stack that is not `n/a` + for it, and all stacks carry the same value + * completeness -- every structural name any source defines is mapped or + exempted with a reason, in both directions (a stale exemption also fails) + +Sources: DOCS-tokens.md + DOCS-motion.md (prose freeze), web_ui tokens.css, +design_system tokens.dart, gatelock _theme.py. + +Run: python3 unified-design-system/scripts/structural_check.py +Exit 0 = agree and complete; 1 = drift, a missing value or an unmapped name. +""" + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +import structural_parse as sp +from structural_map import EXEMPT, STACKS, TOKENS + +ROOT = Path(__file__).resolve().parents[2] +SOURCE_FILES: dict[str, str] = { + "md": "unified-design-system/DOCS-tokens.md", + "motion": "unified-design-system/DOCS-motion.md", + "css": "web_ui/src/tokens.css", + "dart": "design_system/lib/src/tokens.dart", + "tk": "gatelock/gatelock/_theme.py", +} + + +@dataclass +class Result: + """One check outcome.""" + + ok: bool + detail: str + + +def norm(kind: str, raw: str) -> str: + """Reduce a raw source value to a comparable string for its kind.""" + if kind == "shadow": + return " ".join(raw.split()) + nums = re.findall(r"-?\d*\.?\d+", raw) + if kind == "ease": + return ",".join(f"{float(n):g}" for n in nums) + if not nums: + return "0" if "zero" in raw else raw.strip() + return f"{float(nums[0]):g}" + + +def load(root: Path) -> dict[str, sp.Table]: + """Parse every source under `root` into `{stack: {key: raw}}`.""" + + def read(key: str) -> str: + return (root / SOURCE_FILES[key]).read_text(encoding="utf-8") + + return { + "md": {**sp.parse_tokens_md(read("md")), **sp.parse_motion_md(read("motion"))}, + "css": sp.parse_css(read("css")), + "dart": sp.parse_dart(read("dart")), + "tk": sp.parse_tk(read("tk")), + } + + +def check_agreement(src: dict[str, sp.Table]) -> list[Result]: + """Every mapped token must carry one value in every non-`n/a` stack.""" + results: list[Result] = [] + for token in TOKENS: + kind = token.canonical.split(".")[0] + names = {"md": token.canonical, **{s: getattr(token, s) for s in STACKS}} + seen: dict[str, str] = {} + missing: list[str] = [] + for stack, name in names.items(): + if name is None: + continue + if name in src[stack]: + seen[stack] = norm(kind, src[stack][name]) + else: + missing.append(f"{stack}:{name} ({SOURCE_FILES[stack]})") + if missing: + results.append(Result(False, f"{token.canonical:<20} MISSING {', '.join(missing)}")) + elif len(set(seen.values())) > 1: + detail = ", ".join(f"{s}={v}" for s, v in seen.items()) + files = ", ".join(SOURCE_FILES[s] for s in seen) + results.append(Result(False, f"{token.canonical:<20} DRIFT {detail} [{files}]")) + else: + results.append( + Result(True, f"{token.canonical:<20} {next(iter(seen.values()))} x{len(seen)}") + ) + return results + + +def check_completeness(src: dict[str, sp.Table]) -> list[Result]: + """No unmapped name in any source, and no exemption for a vanished name.""" + results: list[Result] = [] + for stack in ("md", *STACKS): + if stack == "md": + mapped = {t.canonical for t in TOKENS} + else: + mapped = {getattr(t, stack) for t in TOKENS if getattr(t, stack)} + exempt = EXEMPT.get(stack, {}) + extras = sorted(n for n in src[stack] if n not in mapped and n not in exempt) + stale = sorted(n for n in exempt if n not in src[stack]) + if extras: + msg = f"{stack:<4} defines unmapped name(s): {', '.join(extras)} -- add to TOKENS or EXEMPT" + results.append(Result(False, msg)) + if stale: + results.append(Result(False, f"{stack:<4} stale exemption(s): {', '.join(stale)}")) + if not extras and not stale: + results.append(Result(True, f"{stack:<4} all {len(src[stack])} parsed name(s) accounted for")) + return results + + +def run(root: Path) -> list[Result]: + """Both halves of the check against the sources under `root`.""" + src = load(root) + return [*check_agreement(src), *check_completeness(src)] + + +def main(root: Path = ROOT) -> int: + """Print the report; non-zero on any failing check.""" + results = run(root) + for r in results: + print(("PASS " if r.ok else "FAIL ") + r.detail) + failures = sum(not r.ok for r in results) + 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/structural_map.py b/unified-design-system/scripts/structural_map.py new file mode 100644 index 0000000..94e7532 --- /dev/null +++ b/unified-design-system/scripts/structural_map.py @@ -0,0 +1,87 @@ +"""The canonical structural-token table: spacing, radius, type, shadow, motion. + +Colour is `palette_map.py`; this is its twin for everything that is not a +colour. Pure data -- parsing and comparison live in `structural_check.py`. + +A canonical key is `.` (`space.sm`, `duration.fast`) and is also +the key the prose freezes (`DOCS-tokens.md`, `DOCS-motion.md`) are parsed +into, so the md stack needs no column of its own. A `None` name means the +token is intentionally absent from that stack and `why` must say so: an +honest `n/a` with a reason, never a fabricated constant. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +STACKS = ("css", "dart", "tk") + + +@dataclass(frozen=True) +class Token: + """One canonical structural token and its name in each code stack.""" + + canonical: str + css: str | None + dart: str | None + tk: str | None + why: str = "" + + def __post_init__(self) -> None: + """Refuse an absence that carries no recorded reason.""" + if None in (self.css, self.dart, self.tk) and not self.why: + msg = f"{self.canonical}: a stack is n/a but `why` is empty" + raise ValueError(msg) + + +_TK_NO_RADIUS = "Tk has no corner-radius primitive: widgets are square." +_TK_NO_MOTION = "Tkinter has no animation framework; a duration would be dead." +_NO_SHADOW = ( + "Dark-first Flutter/Tk surfaces take no shadow (elevation is a fill step); " + "only web light surfaces opt in." +) +_STEPS = ("xs", "sm", "md", "lg", "xl", "xxl") +_TYPE = ("display", "title", "subtitle", "body", "label", "caption") + +TOKENS: tuple[Token, ...] = ( + *( + Token(f"space.{s}", f"--space-{i}", f"AppSpacing.{s}", f"LockSpacing.{s}") + for i, s in enumerate(_STEPS, 1) + ), + *( + Token(f"radius.{s}", f"--radius-{s}", f"AppRadius.{s}", None, _TK_NO_RADIUS) + for s in ("sm", "md", "lg") + ), + *( + Token(f"text.{t}", f"--text-{t}", f"AppTextSize.{t}", f"LockTypography.{t}") + for t in _TYPE + ), + *(Token(f"shadow.{n}", f"--shadow-{n}", None, None, _NO_SHADOW) for n in (1, 2)), + *( + Token(f"duration.{d}", f"--duration-{d}", f"AppDuration.{d}", None, _TK_NO_MOTION) + for d in ("instant", "fast", "base", "slow") + ), + *( + Token(f"ease.{e}", f"--ease-{e}", f"AppCurve.{e}", None, _TK_NO_MOTION) + for e in ("standard", "decelerate", "accelerate") + ), +) + +# Names a source defines that are deliberately outside the compared scales. +# Per stack, name -> reason. A silent skip is the fail-open path this closes. +_TRACKING = "Tracking is an approximate range in the freeze ('tight, e.g. -0.2px'), not a value." +EXEMPT: dict[str, dict[str, str]] = { + "css": { + "--tracking-display": _TRACKING, + "--tracking-title": _TRACKING, + "--tracking-label": _TRACKING, + "--tracking-caption": _TRACKING, + "--font-sans": "Font stacks are per-platform by design (DOCS-tokens.md, Typography).", + "--font-mono": "Font stacks are per-platform by design (DOCS-tokens.md, Typography).", + "--measure": "Web-only prose line-length cap (rule 21); no Dart/Tk text measure.", + }, + "dart": {}, + "tk": { + "LockSpacing.focus_thickness": "Focus-ring width (operability doc), not a scale step.", + }, +} diff --git a/unified-design-system/scripts/structural_parse.py b/unified-design-system/scripts/structural_parse.py new file mode 100644 index 0000000..31af78f --- /dev/null +++ b/unified-design-system/scripts/structural_parse.py @@ -0,0 +1,110 @@ +"""Parsers for `structural_check.py`: one per source, all fail closed. + +Every parser returns `{key: raw_value}` and raises `SystemExit` when the +structure it expects is gone. Returning `{}` would make a moved file read as +"0 parsed, 0 drifted" -- the exact vacuous pass `palette_check.py` was bitten +by -- so a vanished section is an error, never a skip. +""" + +from __future__ import annotations + +import re + +Table = dict[str, str] + + +def _die(msg: str) -> None: + raise SystemExit(f"structural_check: {msg}") + + +def section(text: str, header: str) -> str: + """Body of the `##
` section, up to the next `## ` heading.""" + m = re.search(rf"^## {re.escape(header)}.*?$(.*?)(?=^## |\Z)", text, re.M | re.S) + if not m: + _die(f"section '## {header}' not found; the freeze was renamed or moved") + return m.group(1) + + +def parse_tokens_md(text: str) -> Table: + """Spacing, radius, type and shadow rows from `DOCS-tokens.md`.""" + found: Table = {} + steps = re.search(r"`([^`\n]*·[^`\n]*)`", section(text, "Spacing scale")) + if steps: + for name, val in re.findall(r"(\w+) (\d+)", steps.group(1)): + found[f"space.{name}"] = val + for name, val in re.findall(r"`(\w+) (\d+)`", section(text, "Corner radius scale")): + found[f"radius.{name}"] = val + for name, val in re.findall( + r"^\|\s*`(\w+)`\s*\|\s*(\d+)\s*\|", section(text, "Typography scale"), re.M + ): + found[f"text.{name}"] = val + for level, val in re.findall( + r"Level (\d)[^`\n]*`([^`\n]+)`", section(text, "Shadow policy") + ): + found[f"shadow.{level}"] = val + return found + + +def parse_motion_md(text: str) -> Table: + """Duration and easing rows from `DOCS-motion.md`.""" + found: Table = {} + row = r"^\|\s*`(\w+)`\s*\|\s*`([^`]+)`" + for name, val in re.findall(row, section(text, "Duration"), re.M): + found[f"duration.{name}"] = val + for name, val in re.findall(row, section(text, "Easing"), re.M): + found[f"ease.{name}"] = val + return found + + +def parse_css(text: str) -> Table: + """Root-scope structural custom properties from `tokens.css`. + + Everything from the first `@media` on is overrides (reduced motion, dark + shadows); the freeze is the root block above it. + """ + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) # prose may name tokens + cut = text.find("@media") + if cut == -1: + _die("tokens.css has no @media block; cannot find the end of :root") + decl = re.compile( + r"(--(?:space|radius|text|shadow|duration|ease|tracking|font|measure)[a-z0-9-]*)" + r"\s*:\s*([^;]+);" + ) + return { + name: value.strip() + for name, value in decl.findall(text[:cut]) + if not value.strip().startswith("#") # `--text`, `--text-muted`: colours + } + + +def _classes(text: str, opener: str) -> list[tuple[str, str]]: + """(name, body) of each class; `opener` is the regex that starts one.""" + parts = list(re.finditer(opener, text, re.M)) + if not parts: + return [] + ends = [m.start() for m in parts[1:]] + [len(text)] + return [(m.group(1), text[m.end() : end]) for m, end in zip(parts, ends, strict=True)] + + +def parse_dart(text: str) -> Table: + """`static const double|Duration|Curve` members, keyed `Class.member`.""" + found: Table = {} + member = re.compile(r"static const (?:double|Duration|Curve) (\w+) = ([^;]+);") + for cls, body in _classes(text, r"^abstract final class (\w+)"): + for name, value in member.findall(body): + found[f"{cls}.{name}"] = value.strip() + if not found: + _die("tokens.dart has no double/Duration/Curve constants") + return found + + +def parse_tk(text: str) -> Table: + """Integer dataclass fields, keyed `Class.field`.""" + found: Table = {} + field = re.compile(r"^ {4}(\w+):\s*int\s*=\s*(\d+)", re.M) + for cls, body in _classes(text, r"^class (\w+)"): + for name, value in field.findall(body): + found[f"{cls}.{name}"] = value + if not found: + _die("_theme.py has no int fields; the scales moved out of it") + return found diff --git a/unified-design-system/tests/test_structural_check.py b/unified-design-system/tests/test_structural_check.py new file mode 100644 index 0000000..09c0ec6 --- /dev/null +++ b/unified-design-system/tests/test_structural_check.py @@ -0,0 +1,182 @@ +"""Tests for scripts/structural_check.py: both directions of the checker.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +import structural_check +import structural_map + +REAL_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def root(tmp_path: Path) -> Path: + """A scratch copy of the five source files, mutable per test.""" + for rel in structural_check.SOURCE_FILES.values(): + dest = tmp_path / rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(REAL_ROOT / rel, dest) + return tmp_path + + +def edit(root: Path, stack: str, old: str, new: str) -> None: + path = root / structural_check.SOURCE_FILES[stack] + text = path.read_text(encoding="utf-8") + assert old in text, f"{old!r} not in {path}" + path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def failures(root: Path) -> list[str]: + return [r.detail for r in structural_check.run(root) if not r.ok] + + +def test_real_tree_is_green() -> None: + assert failures(REAL_ROOT) == [] + assert structural_check.main(REAL_ROOT) == 0 + + +@pytest.mark.parametrize( + ("stack", "old", "new", "token"), + [ + ("css", "--space-2: 8px", "--space-2: 9px", "space.sm"), + ("dart", "double sm = 8;", "double sm = 9;", "space.sm"), + ("tk", "sm: int = 8", "sm: int = 9", "space.sm"), + ("css", "--duration-fast: 120ms;", "--duration-fast: 130ms;", "duration.fast"), + ("dart", "milliseconds: 200", "milliseconds: 210", "duration.base"), + ("css", "cubic-bezier(0.3, 0, 1, 1)", "cubic-bezier(0.4, 0, 1, 1)", "ease.accelerate"), + ("dart", "Cubic(0, 0, 0, 1)", "Cubic(0, 0, 0, 2)", "ease.decelerate"), + ("css", "--radius-md: 12px", "--radius-md: 13px", "radius.md"), + ("tk", "title: int = 24", "title: int = 25", "text.title"), + ("css", "0 4px 8px rgba", "0 5px 8px rgba", "shadow.2"), + ("md", "`sm 8` (buttons", "`sm 7` (buttons", "radius.sm"), + ("motion", "`200ms`", "`210ms`", "duration.base"), + ], +) +def test_value_drift_fails_naming_the_token( + root: Path, stack: str, old: str, new: str, token: str +) -> None: + edit(root, stack, old, new) + found = failures(root) + assert any(token in line and "DRIFT" in line for line in found), found + assert structural_check.main(root) == 1 + + +def test_missing_in_a_stack_fails(root: Path) -> None: + edit(root, "css", "--space-2: 8px;", "") + assert any("space.sm" in f and "MISSING" in f for f in failures(root)) + + +def test_unmapped_css_token_fails_completeness(root: Path) -> None: + edit(root, "css", "--space-6: 48px;", "--space-6: 48px; --space-7: 64px;") + assert any("css" in f and "--space-7" in f for f in failures(root)) + + +def test_unmapped_dart_token_fails_completeness(root: Path) -> None: + edit(root, "dart", "static const double xxl = 48;", "static const double xxl = 48;\n static const double huge = 64;") + assert any("dart" in f and "AppSpacing.huge" in f for f in failures(root)) + + +def test_unmapped_tk_token_fails_completeness(root: Path) -> None: + edit(root, "tk", "xxl: int = 48", "xxl: int = 48\n huge: int = 64") + assert any("tk" in f and "LockSpacing.huge" in f for f in failures(root)) + + +def test_unmapped_md_token_fails_completeness(root: Path) -> None: + edit(root, "motion", "| `slow` | `320ms` |", "| `glacial` | `900ms` |\n| `slow` | `320ms` |") + assert any("md" in f and "duration.glacial" in f for f in failures(root)) + + +def test_unmapped_shadow_in_md_fails(root: Path) -> None: + edit(root, "md", "- Level 1 (e.g. a dropdown)", "- Level 3 (e.g. a toast): `0 8px 16px rgba(0, 0, 0, 0.2)`\n - Level 1 (e.g. a dropdown)") + assert any("shadow.3" in f for f in failures(root)) + + +def test_stale_exemption_fails(root: Path) -> None: + edit(root, "css", "--measure: 40rem;", "") + assert any("--measure" in f and "stale" in f for f in failures(root)) + + +def test_missing_section_fails_closed(root: Path) -> None: + edit(root, "md", "## Spacing scale (4px base)", "## Renamed") + with pytest.raises(SystemExit): + structural_check.run(root) + + +def test_missing_class_fails_closed(root: Path) -> None: + edit(root, "tk", "class LockSpacing", "class Renamed") + # Renamed class still has int fields, so the tokens map to nothing. + assert any("LockSpacing" in f for f in failures(root)) + + +def test_no_dart_class_fails_closed(root: Path) -> None: + path = root / structural_check.SOURCE_FILES["dart"] + path.write_text("// empty\n", encoding="utf-8") + with pytest.raises(SystemExit): + structural_check.run(root) + + +def test_no_tk_ints_fails_closed(root: Path) -> None: + path = root / structural_check.SOURCE_FILES["tk"] + path.write_text("x = 1\n", encoding="utf-8") + with pytest.raises(SystemExit): + structural_check.run(root) + + +def test_css_without_media_fails_closed(root: Path) -> None: + path = root / structural_check.SOURCE_FILES["css"] + text = path.read_text(encoding="utf-8") + path.write_text(text.replace("@media", "@supports"), encoding="utf-8") + with pytest.raises(SystemExit): + structural_check.run(root) + + +def test_absent_stack_requires_a_reason() -> None: + with pytest.raises(ValueError, match="why"): + structural_map.Token("space.zz", "--space-zz", None, "x") + + +@pytest.mark.parametrize( + ("kind", "raw", "expected"), + [ + ("space", "8px", "8"), + ("duration", "Duration.zero", "0"), + ("duration", "0ms", "0"), + ("duration", "Duration(milliseconds: 120)", "120"), + ("ease", "cubic-bezier(0.2, 0, 0, 1)", "0.2,0,0,1"), + ("ease", "Cubic(0.2, 0, 0, 1)", "0.2,0,0,1"), + ("shadow", "0 2px 4px", "0 2px 4px"), + ("space", "none", "none"), + ], +) +def test_norm(kind: str, raw: str, expected: str) -> None: + assert structural_check.norm(kind, raw) == expected + + +def test_cli_exit_codes(root: Path) -> None: + script = REAL_ROOT / "unified-design-system/scripts/structural_check.py" + ok = subprocess.run([sys.executable, str(script)], check=False, capture_output=True) + assert ok.returncode == 0 + edit(root, "css", "--space-2: 8px", "--space-2: 9px") + scratch = root / "unified-design-system/scripts" + scratch.mkdir(parents=True) + for name in ("structural_check.py", "structural_map.py", "structural_parse.py"): + shutil.copy(script.parent / name, scratch / name) + bad = subprocess.run( + [sys.executable, str(scratch / "structural_check.py")], + check=False, + capture_output=True, + text=True, + ) + assert bad.returncode == 1 + assert "space.sm" in bad.stdout + assert "DRIFT" in bad.stdout + + +def test_spacing_line_absent_is_not_invented(root: Path) -> None: + edit(root, "md", "`xs 4 · sm 8", "xs 4 · sm 8") + assert any("MISSING" in f and "space." in f for f in failures(root))