From 32df8f6cfa249109c88bdaa752ec2963f7f594dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 5 Jul 2026 18:25:03 +0900 Subject: [PATCH 1/4] Add canonical design-token source (tokens.json) consumed by the dashboard Turns the dashboard's hardcoded palette into a versioned product design-token source of truth, so colors are code-backed rather than ad-hoc. - scanner/dashboard/tokens.json: canonical AppGuardrail color + radius tokens with roles, WCAG contrast notes, and findings.py severity mappings. Semantic names align with the KRDS-based Figma library. - `appguardrail dashboard` now generates /tokens.css from tokens.json (render_tokens_css) and serves it; the HTML links it last so served values win over an inline fallback (page still styled if tokens.css is absent). - Packaged in the wheel (pyproject package-data + MANIFEST.in). - Tests: token source validity, CSS generation, and the /tokens.css route. - Verified: pytest green; headless Chrome resolves --primary #256EF4 from the served tokens.css. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 --- MANIFEST.in | 1 + pyproject.toml | 2 +- scanner/cli/appguardrail.py | 58 ++++++++++++++++++++++++++++++++--- scanner/dashboard/index.html | 6 ++++ scanner/dashboard/tokens.json | 22 +++++++++++++ tests/test_dashboard_core.py | 44 +++++++++++++++++++++++++- 6 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 scanner/dashboard/tokens.json diff --git a/MANIFEST.in b/MANIFEST.in index b934cd6..951adcd 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,6 +3,7 @@ include LICENSE include CHANGELOG.md recursive-include scanner/rules *.yml recursive-include scanner/dashboard *.html +recursive-include scanner/dashboard *.json recursive-include checklists *.md recursive-include docs *.md recursive-include prompts *.md diff --git a/pyproject.toml b/pyproject.toml index ae4834a..12291d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ namespaces = false [tool.setuptools.package-data] "scanner.rules" = ["*.yml"] -"scanner" = ["dashboard/*.html"] +"scanner" = ["dashboard/*.html", "dashboard/*.json"] [tool.interrogate] exclude = ["tests", "build", "dist"] diff --git a/scanner/cli/appguardrail.py b/scanner/cli/appguardrail.py index a1d1ae6..4336e09 100644 --- a/scanner/cli/appguardrail.py +++ b/scanner/cli/appguardrail.py @@ -2695,11 +2695,47 @@ def dashboard_index_path(): return Path(str(resources.files("scanner").joinpath("dashboard", "index.html"))) -def make_dashboard_server(host, port, index_bytes, findings_path): +def dashboard_tokens_path(): + """Locate the canonical design-token source shipped with the package.""" + return Path(str(resources.files("scanner").joinpath("dashboard", "tokens.json"))) + + +# Map canonical token names (tokens.json) to the CSS custom properties the +# dashboard stylesheet consumes. Keeps the JSON the single source of truth. +_TOKEN_CSS_VARS = { + ("color", "background"): "--bg", + ("color", "surface"): "--surface", + ("color", "text-default"): "--text", + ("color", "text-muted"): "--muted", + ("color", "border"): "--border", + ("color", "divider"): "--divider", + ("color", "primary"): "--primary", + ("color", "on-primary"): "--on-primary", + ("color", "critical"): "--crit", + ("color", "high"): "--high", + ("color", "warning"): "--warn", + ("color", "info"): "--info", + ("radius", "base"): "--radius", +} + + +def render_tokens_css(tokens: dict) -> str: + """Render :root CSS custom properties from the design-token source dict.""" + lines = [":root{"] + for (group, key), css_var in _TOKEN_CSS_VARS.items(): + entry = (tokens.get(group) or {}).get(key) + if isinstance(entry, dict) and "value" in entry: + lines.append(f" {css_var}: {entry['value']};") + lines.append("}") + return "\n".join(lines) + "\n" + + +def make_dashboard_server(host, port, index_bytes, findings_path, tokens_css_bytes=b""): """Build (but do not start) an HTTP server that serves the dashboard. - Serves the dashboard HTML at ``/`` and the findings file at - ``/findings.json`` so the page loads regardless of the caller's cwd. + Serves the dashboard HTML at ``/``, the design tokens at ``/tokens.css``, + and the findings file at ``/findings.json`` so the page loads regardless + of the caller's cwd. """ import http.server @@ -2717,6 +2753,8 @@ def do_GET(self): path = self.path.split("?", 1)[0] if path in ("/", "/index.html", "/dashboard/", "/dashboard/index.html"): self._send(index_bytes, "text/html; charset=utf-8") + elif path in ("/tokens.css", "/dashboard/tokens.css"): + self._send(tokens_css_bytes, "text/css; charset=utf-8") elif path in ("/findings.json", "/reports/findings.json"): if findings_path.is_file(): self._send(findings_path.read_bytes(), "application/json") @@ -2754,10 +2792,22 @@ def cmd_dashboard(args): ) print(" The dashboard opens with instructions — reload after generating.\n") + tokens_css = b"" + tokens_file = dashboard_tokens_path() + if tokens_file.is_file(): + try: + tokens_css = render_tokens_css( + json.loads(tokens_file.read_text(encoding="utf-8")) + ).encode("utf-8") + except (ValueError, OSError) as exc: + print(f"⚠️ Could not read design tokens ({exc}); using stylesheet defaults.", file=sys.stderr) + host = getattr(args, "host", "127.0.0.1") port = getattr(args, "port", 8787) try: - server = make_dashboard_server(host, port, index.read_bytes(), findings_path) + server = make_dashboard_server( + host, port, index.read_bytes(), findings_path, tokens_css + ) except OSError as exc: print(f"❌ Cannot start dashboard on {host}:{port} ({exc}).", file=sys.stderr) print("💡 Pass a free port with --port, e.g. --port 8899.", file=sys.stderr) diff --git a/scanner/dashboard/index.html b/scanner/dashboard/index.html index 07409c3..715446f 100644 --- a/scanner/dashboard/index.html +++ b/scanner/dashboard/index.html @@ -12,6 +12,9 @@ ponytail: no framework, no build step. Renders the schema in appguardrail_core/findings.py verbatim. --> + +
diff --git a/scanner/dashboard/tokens.json b/scanner/dashboard/tokens.json new file mode 100644 index 0000000..fa9fabc --- /dev/null +++ b/scanner/dashboard/tokens.json @@ -0,0 +1,22 @@ +{ + "$schema": "appguardrail.design-tokens.v1", + "name": "AppGuardrail Design Tokens", + "description": "Canonical product design tokens for AppGuardrail surfaces (dashboard, future web UI). Source of truth for color and shape. Consumed by scanner/dashboard via the generated /tokens.css route. Semantic names align with the KRDS-based Figma library (color.* / radius.*).", + "color": { + "background": { "value": "#F4F5F7", "role": "app background", "contrast": "surface text >= 4.5:1" }, + "surface": { "value": "#FFFFFF", "role": "card / raised surface", "contrast": "text-default 12.6:1" }, + "text-default": { "value": "#1A1D24", "role": "primary text", "contrast": "on surface >= 12:1" }, + "text-muted": { "value": "#5B6472", "role": "secondary text / captions", "contrast": "on surface >= 4.5:1" }, + "border": { "value": "#D6DAE0", "role": "control / card border" }, + "divider": { "value": "#E7EAEF", "role": "row / section divider" }, + "primary": { "value": "#256EF4", "role": "primary action / focus", "contrast": "on-primary 4.6:1" }, + "on-primary": { "value": "#FFFFFF", "role": "text on primary" }, + "critical": { "value": "#D93B3B", "role": "CRITICAL severity", "maps": "findings.py severity CRITICAL" }, + "high": { "value": "#E06C00", "role": "HIGH severity", "maps": "findings.py severity HIGH" }, + "warning": { "value": "#B7791F", "role": "WARNING severity", "maps": "findings.py severity WARNING" }, + "info": { "value": "#5B6472", "role": "INFO severity / neutral", "maps": "findings.py severity INFO" } + }, + "radius": { + "base": { "value": "10px", "role": "card / control corner radius" } + } +} diff --git a/tests/test_dashboard_core.py b/tests/test_dashboard_core.py index 09eb6c6..d3c8b64 100644 --- a/tests/test_dashboard_core.py +++ b/tests/test_dashboard_core.py @@ -8,7 +8,14 @@ import pytest -from scanner.cli.appguardrail import dashboard_index_path, make_dashboard_server +import json as _json + +from scanner.cli.appguardrail import ( + dashboard_index_path, + dashboard_tokens_path, + make_dashboard_server, + render_tokens_css, +) def _serve(server): @@ -48,6 +55,41 @@ def test_server_serves_index_and_findings(tmp_path): server.server_close() +def test_design_tokens_source_is_valid(): + path = dashboard_tokens_path() + assert path.is_file(), f"design token source missing: {path}" + data = _json.loads(path.read_text()) + # canonical color tokens the dashboard and Figma library depend on + for key in ("background", "surface", "text-default", "primary", "critical", "high", "warning", "info"): + assert key in data["color"], f"missing color token: {key}" + assert data["color"][key]["value"].startswith("#") + + +def test_render_tokens_css_emits_dashboard_vars(): + css = render_tokens_css(_json.loads(dashboard_tokens_path().read_text())) + assert css.startswith(":root{") + # the exact CSS custom properties the stylesheet consumes + for var in ("--primary", "--crit", "--surface", "--text", "--radius"): + assert var in css + # value passthrough + assert "#256EF4" in css # primary + + +def test_server_serves_tokens_css(tmp_path): + findings = tmp_path / "f.json" + findings.write_text('{"findings":[]}') + css = b":root{--primary:#256EF4;}" + server = make_dashboard_server("127.0.0.1", 0, b"DASH", findings, css) + port = server.server_address[1] + _serve(server) + try: + status, body = _get(f"http://127.0.0.1:{port}/tokens.css") + assert status == 200 and b"--primary" in body + finally: + server.shutdown() + server.server_close() + + def test_server_404s_missing_findings(tmp_path): missing = tmp_path / "nope.json" server = make_dashboard_server("127.0.0.1", 0, b"DASH", missing) From 75ae3a19416c18e667dc3b72cb0788191d552917 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 5 Jul 2026 18:30:54 +0900 Subject: [PATCH 2/4] Add High Contrast token mode, applied via prefers-contrast Closes the High Contrast Gap by developing it as real product source rather than leaving it as a KRDS/WCAG candidate. - tokens.json gains a `high-contrast` block (WCAG AAA-oriented overrides: text/border #000, primary #0038A8, stronger status colors). - render_tokens_css emits an `@media (prefers-contrast: more)` :root override, so the dashboard adapts automatically to the user's contrast preference. - Tests: HC block presence + media-query generation. - Verified: served /tokens.css contains the media rule; headless Chrome CSSOM resolves the HC :root override (--primary #0038A8, --text #000000). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 --- scanner/cli/appguardrail.py | 23 ++++++++++++++++++++++- scanner/dashboard/tokens.json | 15 +++++++++++++++ tests/test_dashboard_core.py | 18 ++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/scanner/cli/appguardrail.py b/scanner/cli/appguardrail.py index 4336e09..beca1e0 100644 --- a/scanner/cli/appguardrail.py +++ b/scanner/cli/appguardrail.py @@ -2720,13 +2720,34 @@ def dashboard_tokens_path(): def render_tokens_css(tokens: dict) -> str: - """Render :root CSS custom properties from the design-token source dict.""" + """Render CSS custom properties from the design-token source dict. + + Emits base tokens under ``:root`` and, when present, a ``high-contrast`` + override under ``@media (prefers-contrast: more)`` so the dashboard adapts + to the user's contrast preference automatically. + """ lines = [":root{"] for (group, key), css_var in _TOKEN_CSS_VARS.items(): entry = (tokens.get(group) or {}).get(key) if isinstance(entry, dict) and "value" in entry: lines.append(f" {css_var}: {entry['value']};") lines.append("}") + + hc = tokens.get("high-contrast") or {} + hc_lines = [] + for (group, key), css_var in _TOKEN_CSS_VARS.items(): + if group != "color": + continue + entry = hc.get(key) + if isinstance(entry, dict) and "value" in entry: + hc_lines.append(f" {css_var}: {entry['value']};") + if hc_lines: + lines.append("@media (prefers-contrast: more){") + lines.append(" :root{") + lines.extend(hc_lines) + lines.append(" }") + lines.append("}") + return "\n".join(lines) + "\n" diff --git a/scanner/dashboard/tokens.json b/scanner/dashboard/tokens.json index fa9fabc..a14e546 100644 --- a/scanner/dashboard/tokens.json +++ b/scanner/dashboard/tokens.json @@ -18,5 +18,20 @@ }, "radius": { "base": { "value": "10px", "role": "card / control corner radius" } + }, + "high-contrast": { + "description": "High Contrast mode, applied via @media (prefers-contrast: more). WCAG AAA-oriented; overrides the base color tokens only.", + "background": { "value": "#FFFFFF" }, + "surface": { "value": "#FFFFFF" }, + "text-default": { "value": "#000000" }, + "text-muted": { "value": "#1A1D24" }, + "border": { "value": "#000000" }, + "divider": { "value": "#000000" }, + "primary": { "value": "#0038A8" }, + "on-primary": { "value": "#FFFFFF" }, + "critical": { "value": "#B30000" }, + "high": { "value": "#8A3B00" }, + "warning": { "value": "#6B4A00" }, + "info": { "value": "#000000" } } } diff --git a/tests/test_dashboard_core.py b/tests/test_dashboard_core.py index d3c8b64..2ca0027 100644 --- a/tests/test_dashboard_core.py +++ b/tests/test_dashboard_core.py @@ -75,6 +75,24 @@ def test_render_tokens_css_emits_dashboard_vars(): assert "#256EF4" in css # primary +def test_tokens_include_high_contrast_mode(): + data = _json.loads(dashboard_tokens_path().read_text()) + hc = data["high-contrast"] + # HC must override the key state colors with maximal-contrast values + assert hc["text-default"]["value"] == "#000000" + assert hc["border"]["value"] == "#000000" + assert hc["primary"]["value"].startswith("#") + + +def test_render_tokens_css_emits_high_contrast_media_query(): + css = render_tokens_css(_json.loads(dashboard_tokens_path().read_text())) + assert "@media (prefers-contrast: more)" in css + # HC primary value present inside the media block + assert "#0038A8" in css + # base still first + assert css.index(":root{") < css.index("@media") + + def test_server_serves_tokens_css(tmp_path): findings = tmp_path / "f.json" findings.write_text('{"findings":[]}') From dde3e9db33a2d06fee4daddf2340858e062d7a58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 5 Jul 2026 18:59:47 +0900 Subject: [PATCH 3/4] Enforce no-drift between the inline fallback and tokens.json The dashboard keeps a hardcoded :root fallback (for file:// use where the server-generated /tokens.css 404s). That created a second copy of the palette that could silently drift from the source of truth. Add a test that parses the inline fallback and asserts every custom property equals the value generated from tokens.json (color-notation normalized so #fff == #FFFFFF). Any future token edit that isn't mirrored now fails CI. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 --- tests/test_dashboard_core.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_dashboard_core.py b/tests/test_dashboard_core.py index 2ca0027..61d8634 100644 --- a/tests/test_dashboard_core.py +++ b/tests/test_dashboard_core.py @@ -75,6 +75,34 @@ def test_render_tokens_css_emits_dashboard_vars(): assert "#256EF4" in css # primary +def _norm_color(v): + v = v.strip().lower() + if v.startswith("#") and len(v) == 4: # #abc -> #aabbcc + v = "#" + "".join(c * 2 for c in v[1:]) + return v + + +def _parse_root_vars(css_text): + root = css_text[css_text.index(":root{") + len(":root{"):] + root = root[: root.index("}")] + return { + m.group(1): _norm_color(m.group(2)) + for m in __import__("re").finditer(r"--([\w-]+)\s*:\s*([^;]+);", root) + } + + +def test_inline_fallback_matches_token_source(): + """The hardcoded :root fallback in index.html must not drift from tokens.json.""" + html = dashboard_index_path().read_text() + fallback = _parse_root_vars(html) + source = _parse_root_vars( + render_tokens_css(_json.loads(dashboard_tokens_path().read_text())) + ) + for var, val in source.items(): + assert var in fallback, f"fallback missing {var}" + assert fallback[var] == val, f"{var} drift: fallback {fallback[var]} != tokens.json {val}" + + def test_tokens_include_high_contrast_mode(): data = _json.loads(dashboard_tokens_path().read_text()) hc = data["high-contrast"] From 7d8e41815d04b717e98ca247506994ce7f2c4895 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 5 Jul 2026 20:29:46 +0900 Subject: [PATCH 4/4] Complete the token source: radius/spacing/size scales from the Figma library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tokens.json claimed to be "canonical design tokens" but only carried color + one radius. The Figma library's radius, spacing, and size scales had no code source. Source them so the whole system — not just color — is code-backed. - tokens.json: full radius scale (none..full), spacing scale (0..64), and size scale (control/icon/touch-target), sourced from the Figma collections. - render_tokens_css emits --radius-*/--space-*/--size-* plus a --radius alias (card-alias = lg). Colors and HC mode unchanged. - Dashboard card radius moved onto the scale (10px -> --radius lg = 12px), fixing an off-scale value the earlier "10 == md" parity note got wrong. - Drift test flipped to fallback-subset-of-source so the served tokens.css can expose the full scale while the inline fallback stays minimal. - Verified: pytest green; headless Chrome resolves --radius 12px, --radius-md 8px, --space-16, --size-touch-target 44px; card renders on-scale, no regress. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 --- scanner/cli/appguardrail.py | 73 +++++++++++++++++++++-------------- scanner/dashboard/index.html | 2 +- scanner/dashboard/tokens.json | 37 +++++++++++++++++- tests/test_dashboard_core.py | 33 ++++++++++++++-- 4 files changed, 110 insertions(+), 35 deletions(-) diff --git a/scanner/cli/appguardrail.py b/scanner/cli/appguardrail.py index beca1e0..4bbd6b8 100644 --- a/scanner/cli/appguardrail.py +++ b/scanner/cli/appguardrail.py @@ -2700,47 +2700,64 @@ def dashboard_tokens_path(): return Path(str(resources.files("scanner").joinpath("dashboard", "tokens.json"))) -# Map canonical token names (tokens.json) to the CSS custom properties the -# dashboard stylesheet consumes. Keeps the JSON the single source of truth. -_TOKEN_CSS_VARS = { - ("color", "background"): "--bg", - ("color", "surface"): "--surface", - ("color", "text-default"): "--text", - ("color", "text-muted"): "--muted", - ("color", "border"): "--border", - ("color", "divider"): "--divider", - ("color", "primary"): "--primary", - ("color", "on-primary"): "--on-primary", - ("color", "critical"): "--crit", - ("color", "high"): "--high", - ("color", "warning"): "--warn", - ("color", "info"): "--info", - ("radius", "base"): "--radius", +# Map canonical color token names (tokens.json) to the CSS custom properties the +# dashboard stylesheet consumes. Scales (radius/space/size) are emitted +# generically. Keeps tokens.json the single source of truth. +_COLOR_CSS_VARS = { + "background": "--bg", + "surface": "--surface", + "text-default": "--text", + "text-muted": "--muted", + "border": "--border", + "divider": "--divider", + "primary": "--primary", + "on-primary": "--on-primary", + "critical": "--crit", + "high": "--high", + "warning": "--warn", + "info": "--info", } def render_tokens_css(tokens: dict) -> str: """Render CSS custom properties from the design-token source dict. - Emits base tokens under ``:root`` and, when present, a ``high-contrast`` - override under ``@media (prefers-contrast: more)`` so the dashboard adapts - to the user's contrast preference automatically. + Emits colors (mapped to the dashboard's var names), the radius/space/size + scales (as ``--radius-*`` / ``--space-*`` / ``--size-*``, plus a ``--radius`` + alias for the default card radius), and a ``@media (prefers-contrast: more)`` + color override so the dashboard adapts to the user's contrast preference. """ + color = tokens.get("color") or {} + radius = tokens.get("radius") or {} + space = tokens.get("space") or {} + size = tokens.get("size") or {} + lines = [":root{"] - for (group, key), css_var in _TOKEN_CSS_VARS.items(): - entry = (tokens.get(group) or {}).get(key) + for key, css_var in _COLOR_CSS_VARS.items(): + entry = color.get(key) if isinstance(entry, dict) and "value" in entry: lines.append(f" {css_var}: {entry['value']};") + # radius scale + --radius alias (default card radius) + for key, entry in radius.items(): + if isinstance(entry, dict) and "value" in entry: + lines.append(f" --radius-{key}: {entry['value']};") + alias = radius.get("card-alias") + if alias and isinstance(radius.get(alias), dict): + lines.append(f" --radius: {radius[alias]['value']};") + for key, entry in space.items(): + if isinstance(entry, dict) and "value" in entry: + lines.append(f" --space-{key}: {entry['value']};") + for key, entry in size.items(): + if isinstance(entry, dict) and "value" in entry: + lines.append(f" --size-{key}: {entry['value']};") lines.append("}") hc = tokens.get("high-contrast") or {} - hc_lines = [] - for (group, key), css_var in _TOKEN_CSS_VARS.items(): - if group != "color": - continue - entry = hc.get(key) - if isinstance(entry, dict) and "value" in entry: - hc_lines.append(f" {css_var}: {entry['value']};") + hc_lines = [ + f" {css_var}: {hc[key]['value']};" + for key, css_var in _COLOR_CSS_VARS.items() + if isinstance(hc.get(key), dict) and "value" in hc[key] + ] if hc_lines: lines.append("@media (prefers-contrast: more){") lines.append(" :root{") diff --git a/scanner/dashboard/index.html b/scanner/dashboard/index.html index 715446f..3523359 100644 --- a/scanner/dashboard/index.html +++ b/scanner/dashboard/index.html @@ -19,7 +19,7 @@ --bg:#F4F5F7; --surface:#FFFFFF; --text:#1A1D24; --muted:#5B6472; --border:#D6DAE0; --divider:#E7EAEF; --primary:#256EF4; --on-primary:#fff; --crit:#D93B3B; --high:#E06C00; --warn:#B7791F; --info:#5B6472; - --radius:10px; + --radius:12px; } *{box-sizing:border-box} body{margin:0;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;background:var(--bg);color:var(--text)} diff --git a/scanner/dashboard/tokens.json b/scanner/dashboard/tokens.json index a14e546..91dbe19 100644 --- a/scanner/dashboard/tokens.json +++ b/scanner/dashboard/tokens.json @@ -1,7 +1,7 @@ { "$schema": "appguardrail.design-tokens.v1", "name": "AppGuardrail Design Tokens", - "description": "Canonical product design tokens for AppGuardrail surfaces (dashboard, future web UI). Source of truth for color and shape. Consumed by scanner/dashboard via the generated /tokens.css route. Semantic names align with the KRDS-based Figma library (color.* / radius.*).", + "description": "Canonical product design tokens for AppGuardrail surfaces (dashboard, future web UI). Source of truth for color, radius, spacing, and sizing. Consumed by scanner/dashboard via the generated /tokens.css route. Scales are sourced from the KRDS-based Figma library so design and code stay in parity.", "color": { "background": { "value": "#F4F5F7", "role": "app background", "contrast": "surface text >= 4.5:1" }, "surface": { "value": "#FFFFFF", "role": "card / raised surface", "contrast": "text-default 12.6:1" }, @@ -17,7 +17,40 @@ "info": { "value": "#5B6472", "role": "INFO severity / neutral", "maps": "findings.py severity INFO" } }, "radius": { - "base": { "value": "10px", "role": "card / control corner radius" } + "description": "Corner radius scale (sourced from Figma Radius collection). --radius aliases 'lg' (default card radius).", + "card-alias": "lg", + "none": { "value": "0" }, + "xs": { "value": "2px" }, + "sm": { "value": "4px" }, + "md": { "value": "8px" }, + "lg": { "value": "12px" }, + "xl": { "value": "16px" }, + "full": { "value": "9999px", "role": "pills / dots" } + }, + "space": { + "description": "Spacing scale in px (sourced from Figma Spacing collection).", + "0": { "value": "0" }, + "2": { "value": "2px" }, + "4": { "value": "4px" }, + "8": { "value": "8px" }, + "12": { "value": "12px" }, + "16": { "value": "16px" }, + "20": { "value": "20px" }, + "24": { "value": "24px" }, + "32": { "value": "32px" }, + "40": { "value": "40px" }, + "48": { "value": "48px" }, + "64": { "value": "64px" } + }, + "size": { + "description": "Control and icon sizing (sourced from Figma Size collection).", + "control-sm": { "value": "32px" }, + "control-md": { "value": "40px" }, + "control-lg": { "value": "48px" }, + "icon-sm": { "value": "16px" }, + "icon-md": { "value": "20px" }, + "icon-lg": { "value": "24px" }, + "touch-target": { "value": "44px", "role": "min interactive hit area (WCAG 2.5.5)" } }, "high-contrast": { "description": "High Contrast mode, applied via @media (prefers-contrast: more). WCAG AAA-oriented; overrides the base color tokens only.", diff --git a/tests/test_dashboard_core.py b/tests/test_dashboard_core.py index 61d8634..9511bab 100644 --- a/tests/test_dashboard_core.py +++ b/tests/test_dashboard_core.py @@ -92,15 +92,40 @@ def _parse_root_vars(css_text): def test_inline_fallback_matches_token_source(): - """The hardcoded :root fallback in index.html must not drift from tokens.json.""" + """Every var in the index.html fallback must match tokens.json (no drift). + + Direction is fallback ⊆ source: the served tokens.css may expose more + (full scales); the inline fallback only needs the vars the page consumes, + and each must equal the canonical value. + """ html = dashboard_index_path().read_text() fallback = _parse_root_vars(html) source = _parse_root_vars( render_tokens_css(_json.loads(dashboard_tokens_path().read_text())) ) - for var, val in source.items(): - assert var in fallback, f"fallback missing {var}" - assert fallback[var] == val, f"{var} drift: fallback {fallback[var]} != tokens.json {val}" + for var, val in fallback.items(): + assert var in source, f"fallback var {var} not in tokens.json source" + assert source[var] == val, f"{var} drift: fallback {val} != tokens.json {source[var]}" + + +def test_tokens_include_full_scales(): + data = _json.loads(dashboard_tokens_path().read_text()) + # radius scale sourced from Figma + for k, v in {"none": "0", "sm": "4px", "md": "8px", "lg": "12px", "xl": "16px"}.items(): + assert data["radius"][k]["value"] == v + # spacing scale + for k in ("0", "4", "8", "16", "24", "64"): + assert k in data["space"] + # sizes incl. WCAG touch target + assert data["size"]["touch-target"]["value"] == "44px" + + +def test_render_tokens_css_emits_scales(): + css = render_tokens_css(_json.loads(dashboard_tokens_path().read_text())) + for var in ("--radius-md", "--radius-full", "--space-16", "--size-touch-target"): + assert var in css, f"missing scale var {var}" + # --radius alias resolves to the card-alias (lg = 12px) + assert "--radius: 12px;" in css def test_tokens_include_high_contrast_mode():