|
| 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 | +})(); |
0 commit comments