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..4bbd6b8 100644 --- a/scanner/cli/appguardrail.py +++ b/scanner/cli/appguardrail.py @@ -2695,11 +2695,85 @@ 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 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 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 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 = [ + 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{") + lines.extend(hc_lines) + lines.append(" }") + 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 +2791,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 +2830,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..3523359 100644 --- a/scanner/dashboard/index.html +++ b/scanner/dashboard/index.html @@ -12,11 +12,14 @@ 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..91dbe19 --- /dev/null +++ b/scanner/dashboard/tokens.json @@ -0,0 +1,70 @@ +{ + "$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, 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" }, + "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": { + "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.", + "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 09eb6c6..9511bab 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,112 @@ 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 _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(): + """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 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(): + 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":[]}') + 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)