Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
96 changes: 92 additions & 4 deletions scanner/cli/appguardrail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion scanner/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
ponytail: no framework, no build step. Renders the schema in appguardrail_core/findings.py verbatim.
-->
<style>
/* Fallback tokens — keep the page styled if /tokens.css is unavailable.
The canonical source is scanner/dashboard/tokens.json, served at
/tokens.css (linked after this block so served values win). */
:root{
--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)}
Expand Down Expand Up @@ -72,6 +75,9 @@
.drop{margin-top:14px}
a{color:var(--primary)}
</style>
<!-- Canonical design tokens (scanner/dashboard/tokens.json) served at /tokens.css;
loaded last so served values override the inline fallback above. -->
<link rel="stylesheet" href="tokens.css">
</head>
<body>
<header>
Expand Down
70 changes: 70 additions & 0 deletions scanner/dashboard/tokens.json
Original file line number Diff line number Diff line change
@@ -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" }
}
}
115 changes: 114 additions & 1 deletion tests/test_dashboard_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"<html>DASH</html>", 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"<html>DASH</html>", missing)
Expand Down