utils/unified-design-system/scripts/structural_map.py
Krzysztof kuhy Rudnicki cbcee98d94 Add structural_check: spacing/radius/type/motion drift
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 08:18:27 +01:00

88 lines
3.2 KiB
Python

"""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 `<kind>.<name>` (`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.",
},
}