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)