Skip to content

Commit 4e70565

Browse files
committed
feat(browser): command palette (Cmd/Ctrl-K) + light-mode theme fixes (B0)
Adds the command palette and fixes theme correctness for the new B0/B1 components — both raised reviewing the first redesign screenshots. Command palette (command-palette.js, vanilla + CSP-safe): Cmd/Ctrl-K (or the ⌘K header chip) opens a fuzzy launcher over the page's own nav — views, severity filters, products/scanners, recent runs — with ↑↓ + Enter. It builds its overlay in the DOM and indexes existing links (no inline script, no data blob) and no-ops with JS off. The ⌘K hint is platform-aware: macOS shows ⌘K, Windows/Linux show "Ctrl K" (there's no Command key) — the keybinding already accepts either. Light-mode fix: the SVG charts hardcoded dark-theme colours (donut centre text, track, bar labels) that were invisible/wrong under [data-theme=light]. They now reference the theme tokens (var(--fg) / var(--surface-alt) / var(--fg-muted)) that flip with the theme — inline SVG inherits CSS custom properties — while severity hues stay fixed. Verified with a forced light-mode capture (docs/images/browser/dashboard-light.png) and guarded by a test that the charts emit theme vars, not hardcoded hexes. The palette / card / chart-card CSS already used flipping tokens. Docs: command-palette + light/dark sections in view-browser.md; screenshots regenerated (now with the ⌘K chip); capture script reproduces the light + palette shots (preferredColorScheme=1 / #command).
1 parent 342ebbc commit 4e70565

14 files changed

Lines changed: 316 additions & 12 deletions

File tree

.ai/architecture.yaml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ components:
132132
override), trend_line (pathLength=1 for CSS draw-on), bar_chart — labels XML-escaped. Lives in core so
133133
the browser dashboard and the future PDF report share one renderer. Drives the browser dashboard's
134134
severity donut + findings-over-time trend + by-scanner bars, with count-up.js + a prefers-reduced-motion
135-
gated motion layer (View Transitions API, staggered reveal, draw-on) in viewers/browser/static.
135+
gated motion layer (View Transitions API, staggered reveal, draw-on) and a Cmd/Ctrl-K command palette
136+
(command-palette.js — vanilla, CSP-safe, indexes page nav links) in viewers/browser/static.
136137
viewers/: '`argus view` interfaces (optional extras)'
137138
viewers/__init__.py: ViewerUnavailable shared exception
138139
viewers/terminal/: '`argus view --interface=terminal` — Textual TUI ([terminal] extra). Includes
@@ -774,7 +775,8 @@ docsite:
774775
override), trend_line (pathLength=1 for CSS draw-on), bar_chart — labels XML-escaped. Lives in core so
775776
the browser dashboard and the future PDF report share one renderer. Drives the browser dashboard's
776777
severity donut + findings-over-time trend + by-scanner bars, with count-up.js + a prefers-reduced-motion
777-
gated motion layer (View Transitions API, staggered reveal, draw-on) in viewers/browser/static.
778+
gated motion layer (View Transitions API, staggered reveal, draw-on) and a Cmd/Ctrl-K command palette
779+
(command-palette.js — vanilla, CSP-safe, indexes page nav links) in viewers/browser/static.
778780
viewers/: '`argus view` interfaces (optional extras)'
779781
viewers/__init__.py: ViewerUnavailable shared exception
780782
viewers/terminal/: '`argus view --interface=terminal` — Textual TUI ([terminal] extra). Includes

argus/core/svg_charts.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def donut(
5959
f'<svg viewBox="0 0 {size} {size}" width="{size}" height="{size}" '
6060
f'role="img" aria-label="{_esc(title or "severity breakdown")}">',
6161
f'<circle cx="{cx}" cy="{cy}" r="{radius:.2f}" fill="none" '
62-
f'stroke="#16211c" stroke-width="{thickness}"/>',
62+
f'stroke="var(--surface-alt, #16211c)" stroke-width="{thickness}"/>',
6363
]
6464
offset = 0.0
6565
if total > 0:
@@ -78,7 +78,7 @@ def donut(
7878
offset += seg_len
7979
parts.append(
8080
f'<text x="{cx}" y="{cy}" text-anchor="middle" dominant-baseline="central" '
81-
f'font-size="{size // 6}" fill="#eaf2ea">{center_value}</text>'
81+
f'font-size="{size // 6}" fill="var(--fg, #eaf2ea)">{center_value}</text>'
8282
)
8383
parts.append("</svg>")
8484
return "".join(parts)
@@ -156,11 +156,12 @@ def bar_chart(
156156
bar_w = max(1, track_w * value / largest) if value else 0
157157
text_y = y + bar_height * 0.72
158158
parts.append(
159-
f'<text x="0" y="{text_y:.1f}" font-size="12" fill="#9fb09f">{_esc(name)}</text>'
159+
f'<text x="0" y="{text_y:.1f}" font-size="12" '
160+
f'fill="var(--fg-muted, #9fb09f)">{_esc(name)}</text>'
160161
f'<rect x="{label_w}" y="{y}" width="{bar_w:.1f}" height="{bar_height}" '
161162
f'rx="2" fill="{_esc(fill)}"><title>{_esc(name)}: {value}</title></rect>'
162163
f'<text x="{label_w + bar_w + 6:.1f}" y="{text_y:.1f}" font-size="12" '
163-
f'fill="#eaf2ea">{value}</text>'
164+
f'fill="var(--fg, #eaf2ea)">{value}</text>'
164165
)
165166
y += bar_height + gap
166167
parts.append("</svg>")

argus/tests/core/test_svg_charts.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,22 @@ def test_label_escaped(self):
7575
assert "&amp;" in bar_chart([("a&b", 1)])
7676

7777

78+
class TestThemeAware:
79+
# Light-mode guard: structural colors must reference theme tokens (which
80+
# flip light/dark) rather than hardcoded dark hexes, so inline SVG renders
81+
# correctly under [data-theme="light"]. Severity hues stay fixed.
82+
def test_donut_uses_theme_tokens(self):
83+
svg = donut([("a", 3, "#e74c3c")])
84+
assert "var(--fg" in svg # centre total text
85+
assert "var(--surface-alt" in svg # track ring
86+
assert "#e74c3c" in svg # severity hue stays fixed
87+
88+
def test_bar_chart_uses_theme_tokens(self):
89+
svg = bar_chart([("bandit", 5)])
90+
assert "var(--fg-muted" in svg # label
91+
assert "var(--fg," in svg # value
92+
93+
7894
class TestSeverityDonut:
7995
def test_maps_severity_to_brand_colors(self):
8096
svg = severity_donut([(Severity.CRITICAL, 2), (Severity.LOW, 1)])

argus/tests/viewers/browser/test_dashboard.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,17 @@ def test_renders_charts(self, tmp_path):
197197
assert 'data-count="1"' in resp.text # count-up hook on the total card
198198
assert "count-up.js" in resp.text
199199

200+
def test_command_palette_wired(self, tmp_path):
201+
# Phase B0: the command palette (Cmd/Ctrl-K) is loaded on every page,
202+
# with the ⌘K hint affordance and data-cmd jump targets on the cards.
203+
_write_results(tmp_path, _sample_payload())
204+
app = create_app(root=str(tmp_path))
205+
resp = TestClient(app).get("/")
206+
assert resp.status_code == 200
207+
assert "command-palette.js" in resp.text
208+
assert "cmdk-hint" in resp.text and "data-cmdk-open" in resp.text
209+
assert 'data-cmd="Critical findings"' in resp.text
210+
200211
def test_scan_query_param_overrides_launch_root_within_scope(self, tmp_path):
201212
# ``?scan=`` can point at any directory or file *inside* the
202213
# launch root. Launch at the parent; load a specific run below.

argus/viewers/browser/static/argus.css

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,3 +1115,88 @@ summary:focus-visible {
11151115
scroll-behavior: auto !important;
11161116
}
11171117
}
1118+
1119+
/* ===========================================================================
1120+
Phase B0 — command palette (Cmd/Ctrl-K). Overlay built by
1121+
command-palette.js; styled here. CSP-safe (no inline styles).
1122+
=========================================================================== */
1123+
1124+
/* The discoverable ⌘K chip in the header nav. */
1125+
.cmdk-hint {
1126+
display: inline-flex;
1127+
align-items: center;
1128+
background: transparent;
1129+
border: 1px solid var(--border);
1130+
border-radius: 6px;
1131+
padding: 0.15rem 0.45rem;
1132+
color: var(--argus-muted-text);
1133+
cursor: pointer;
1134+
transition: border-color 0.15s var(--argus-ease, ease), color 0.15s var(--argus-ease, ease);
1135+
}
1136+
.cmdk-hint:hover { border-color: var(--argus-primary-green); color: var(--argus-light-text); }
1137+
.cmdk-hint kbd {
1138+
font: inherit;
1139+
font-size: 0.8rem;
1140+
letter-spacing: 0.02em;
1141+
}
1142+
1143+
.cmdk-overlay {
1144+
position: fixed;
1145+
inset: 0;
1146+
z-index: 1000;
1147+
display: flex;
1148+
justify-content: center;
1149+
align-items: flex-start;
1150+
padding-top: 12vh;
1151+
background: rgba(4, 7, 6, 0.6);
1152+
backdrop-filter: blur(2px);
1153+
}
1154+
.cmdk-overlay[hidden] { display: none; }
1155+
1156+
.cmdk-box {
1157+
width: min(560px, 92vw);
1158+
max-height: 60vh;
1159+
display: flex;
1160+
flex-direction: column;
1161+
background: var(--surface);
1162+
border: 1px solid var(--argus-primary-green);
1163+
border-radius: 12px;
1164+
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
1165+
overflow: hidden;
1166+
}
1167+
@media (prefers-reduced-motion: no-preference) {
1168+
.cmdk-overlay:not([data-reduce]) .cmdk-box {
1169+
animation: cmdk-pop 0.16s var(--argus-ease) both;
1170+
}
1171+
@keyframes cmdk-pop {
1172+
from { opacity: 0; transform: translateY(-8px) scale(0.98); }
1173+
to { opacity: 1; transform: none; }
1174+
}
1175+
}
1176+
1177+
.cmdk-input {
1178+
border: none;
1179+
border-bottom: 1px solid var(--border);
1180+
background: transparent;
1181+
color: var(--argus-light-text);
1182+
font-size: 1.05rem;
1183+
padding: 0.9rem 1.1rem;
1184+
outline: none;
1185+
}
1186+
.cmdk-input::placeholder { color: var(--argus-muted-text); }
1187+
1188+
.cmdk-list {
1189+
list-style: none;
1190+
margin: 0;
1191+
padding: 0.35rem;
1192+
overflow-y: auto;
1193+
}
1194+
.cmdk-item {
1195+
padding: 0.55rem 0.8rem;
1196+
border-radius: 8px;
1197+
color: var(--argus-light-text);
1198+
cursor: pointer;
1199+
}
1200+
.cmdk-item.is-selected {
1201+
background: color-mix(in srgb, var(--argus-primary-green) 22%, transparent);
1202+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/* argus serve — command palette (Phase B0).
2+
*
3+
* Cmd/Ctrl-K opens a fuzzy launcher over the page's own navigation: jump to
4+
* any view, severity filter, product, or scanner without the mouse — browser
5+
* parity with the TUI's Ctrl+P, and the kind of touch that makes a tool feel
6+
* like a product.
7+
*
8+
* Progressive enhancement + CSP-friendly: the overlay is built in the DOM by
9+
* this 'self' script (no inline JS), commands are scraped from existing
10+
* links already on the page (no inline data blob), and with no JS the page
11+
* navigates normally. Honours prefers-reduced-motion. Append `#command` to a
12+
* URL to auto-open it (used to capture docs screenshots). */
13+
(function () {
14+
"use strict";
15+
16+
var reduce =
17+
window.matchMedia &&
18+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
19+
20+
// Collect navigable commands from the page. ``[data-cmd]`` marks a curated
21+
// target (its value is the label); we also fold in the header nav links.
22+
function collectCommands() {
23+
var seen = {};
24+
var out = [];
25+
function add(label, href) {
26+
label = (label || "").replace(/\s+/g, " ").trim();
27+
if (!label || !href || seen[label + "|" + href]) return;
28+
seen[label + "|" + href] = true;
29+
out.push({ label: label, href: href });
30+
}
31+
document.querySelectorAll("[data-cmd][href]").forEach(function (el) {
32+
add(el.getAttribute("data-cmd"), el.href);
33+
});
34+
document.querySelectorAll("header nav a[href]").forEach(function (el) {
35+
add(el.textContent, el.href);
36+
});
37+
return out;
38+
}
39+
40+
// Subsequence fuzzy match: every query char appears in order. Empty query
41+
// matches everything (recently-built order preserved).
42+
function matches(query, label) {
43+
if (!query) return true;
44+
query = query.toLowerCase();
45+
label = label.toLowerCase();
46+
var qi = 0;
47+
for (var i = 0; i < label.length && qi < query.length; i++) {
48+
if (label[i] === query[qi]) qi++;
49+
}
50+
return qi === query.length;
51+
}
52+
53+
var commands = [];
54+
var overlay, input, list, selected = 0, filtered = [];
55+
56+
function build() {
57+
overlay = document.createElement("div");
58+
overlay.className = "cmdk-overlay";
59+
overlay.setAttribute("role", "dialog");
60+
overlay.setAttribute("aria-modal", "true");
61+
overlay.setAttribute("aria-label", "Command palette");
62+
overlay.hidden = true;
63+
if (reduce) overlay.setAttribute("data-reduce", "");
64+
65+
var box = document.createElement("div");
66+
box.className = "cmdk-box";
67+
input = document.createElement("input");
68+
input.className = "cmdk-input";
69+
input.type = "text";
70+
input.setAttribute("placeholder", "Jump to… (type to filter, ↑↓ + Enter)");
71+
input.setAttribute("aria-label", "Filter commands");
72+
list = document.createElement("ul");
73+
list.className = "cmdk-list";
74+
box.appendChild(input);
75+
box.appendChild(list);
76+
overlay.appendChild(box);
77+
document.body.appendChild(overlay);
78+
79+
overlay.addEventListener("mousedown", function (e) {
80+
if (e.target === overlay) close();
81+
});
82+
input.addEventListener("input", render);
83+
input.addEventListener("keydown", onKey);
84+
}
85+
86+
function render() {
87+
var q = input.value.trim();
88+
filtered = commands.filter(function (c) { return matches(q, c.label); });
89+
selected = 0;
90+
while (list.firstChild) list.removeChild(list.firstChild); // no innerHTML
91+
filtered.forEach(function (c, i) {
92+
var li = document.createElement("li");
93+
li.className = "cmdk-item" + (i === selected ? " is-selected" : "");
94+
li.textContent = c.label;
95+
li.addEventListener("mouseenter", function () { selected = i; paint(); });
96+
li.addEventListener("mousedown", function (e) { e.preventDefault(); go(c); });
97+
list.appendChild(li);
98+
});
99+
}
100+
101+
function paint() {
102+
var items = list.children;
103+
for (var i = 0; i < items.length; i++) {
104+
items[i].className = "cmdk-item" + (i === selected ? " is-selected" : "");
105+
}
106+
if (items[selected]) items[selected].scrollIntoView({ block: "nearest" });
107+
}
108+
109+
function onKey(e) {
110+
if (e.key === "ArrowDown") { e.preventDefault(); selected = Math.min(selected + 1, filtered.length - 1); paint(); }
111+
else if (e.key === "ArrowUp") { e.preventDefault(); selected = Math.max(selected - 1, 0); paint(); }
112+
else if (e.key === "Enter") { e.preventDefault(); if (filtered[selected]) go(filtered[selected]); }
113+
else if (e.key === "Escape") { e.preventDefault(); close(); }
114+
}
115+
116+
function go(cmd) { window.location.href = cmd.href; }
117+
118+
function open() {
119+
if (!overlay) build();
120+
commands = collectCommands();
121+
input.value = "";
122+
render();
123+
overlay.hidden = false;
124+
input.focus();
125+
}
126+
127+
function close() { if (overlay) overlay.hidden = true; }
128+
129+
document.addEventListener("keydown", function (e) {
130+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
131+
e.preventDefault();
132+
overlay && !overlay.hidden ? close() : open();
133+
}
134+
});
135+
136+
// A clickable affordance (the ⌘K hint in the header) opens it too, and we
137+
// localise its label to the platform's modifier: macOS shows ⌘, everyone
138+
// else (Windows / Linux — no Command key) shows Ctrl. The keybinding itself
139+
// already accepts either (metaKey || ctrlKey); this just fixes the *label*.
140+
var isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent || "");
141+
document.querySelectorAll("[data-cmdk-open]").forEach(function (el) {
142+
el.addEventListener("click", function (e) { e.preventDefault(); open(); });
143+
var kbd = el.querySelector("kbd");
144+
if (kbd) kbd.textContent = isMac ? "⌘K" : "Ctrl K";
145+
});
146+
147+
// Auto-open for screenshots / deep-links.
148+
if (window.location.hash === "#command") {
149+
if (document.readyState !== "loading") open();
150+
else document.addEventListener("DOMContentLoaded", open);
151+
}
152+
})();

argus/viewers/browser/templates/base.html.j2

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@
6060
<a href="/picker"
6161
{% if _active_nav == "/picker" %}aria-current="page"{% endif %}>Switch scan</a>
6262

63+
{#- Command palette hint (Cmd/Ctrl-K). The keybinding works without
64+
this button; it's a discoverable affordance + a click target.
65+
command-palette.js wires the click and the keydown; with no JS
66+
it's a harmless inert chip. -#}
67+
<button type="button" class="cmdk-hint" data-cmdk-open
68+
aria-label="Open command palette (Control or Command + K)">
69+
<kbd>⌘K</kbd>
70+
</button>
71+
6372
{#- Theme toggle — flips between dark and light via a
6473
data-theme attribute on <html>. Defaults to the user's
6574
prefers-color-scheme until they click it, after which the
@@ -95,5 +104,9 @@
95104
prefers-color-scheme rule in argus.css has already done the
96105
right thing. -#}
97106
<script src="/static/theme-toggle.js" defer></script>
107+
{#- Command palette (Cmd/Ctrl-K): a fuzzy launcher over the page's nav.
108+
Vanilla, external (strict CSP), builds its overlay in the DOM and
109+
indexes existing links — no-ops cleanly with no JS. -#}
110+
<script src="/static/command-palette.js" defer></script>
98111
</body>
99112
</html>

argus/viewers/browser/templates/summary.html.j2

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
severity-neutral CSS rules ("card") set layout and the
3737
severity-specific modifier sets the border accent. -#}
3838
<div class="cards">
39-
<a class="card" href="/findings{% if scan_param %}?scan={{ scan_param | urlencode }}{% endif %}">
39+
<a class="card" href="/findings{% if scan_param %}?scan={{ scan_param | urlencode }}{% endif %}" data-cmd="View all findings">
4040
<div class="label">Total findings</div>
4141
<div class="value" data-count="{{ summary.total }}">{{ summary.total }}</div>
4242
</a>
@@ -50,7 +50,7 @@
5050
%}
5151
{% set count = summary.by_severity.get(sev, 0) %}
5252
{% if count %}
53-
<a class="card {{ sev }}" href="{{ findings_link('min_severity', sev) | trim }}">
53+
<a class="card {{ sev }}" href="{{ findings_link('min_severity', sev) | trim }}" data-cmd="{{ sev.capitalize() }} findings">
5454
<div class="label">{{ icon }} {{ sev.capitalize() }}</div>
5555
<div class="value" data-count="{{ count }}">{{ count }}</div>
5656
</a>
146 KB
Loading
261 KB
Loading

0 commit comments

Comments
 (0)