penthera 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +170 -0
- package/README.md +128 -663
- package/bin/scan.js +19 -0
- package/docs/commands/README.md +23 -0
- package/docs/commands/action.md +21 -0
- package/docs/commands/agents.md +38 -0
- package/docs/commands/deps.md +115 -0
- package/docs/commands/fix.md +53 -0
- package/docs/commands/hook.md +69 -0
- package/docs/commands/install.md +53 -0
- package/docs/commands/scan.md +100 -0
- package/docs/commands/skill.md +58 -0
- package/docs/compare/README.md +25 -0
- package/docs/compare/github.md +60 -0
- package/docs/compare/semgrep.md +54 -0
- package/docs/compare/snyk.md +62 -0
- package/docs/compare/socket.md +78 -0
- package/docs/compare/zap.md +59 -0
- package/docs/dashboard.md +244 -0
- package/docs/evidence.md +8 -4
- package/docs/examples/github-actions-deps.yml +4 -4
- package/docs/examples/github-actions-scan.yml +4 -4
- package/docs/index.md +9 -0
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +4 -3
- package/docs/privacy.md +38 -0
- package/docs/references.bib +10 -0
- package/docs/references.md +1 -0
- package/docs/research/socket-positioning-notes.md +183 -0
- package/docs/use-cases/README.md +14 -0
- package/docs/use-cases/agencies.md +71 -0
- package/docs/use-cases/ai-coding-teams.md +81 -0
- package/docs/use-cases/eu-sme-compliance.md +68 -0
- package/docs/use-cases/open-source-maintainers.md +75 -0
- package/docs/use-cases/solo-founder.md +65 -0
- package/lib/blackbox/email-dns.js +5 -0
- package/lib/compliance/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +290 -30
- package/lib/deps/lockfile.js +25 -2
- package/lib/deps/popular-pypi.json +1 -0
- package/lib/deps/python/capabilities.js +447 -0
- package/lib/deps/python/index.js +234 -0
- package/lib/deps/python/lockfiles.js +359 -0
- package/lib/deps/python/source.js +396 -0
- package/lib/deps/python/toml.js +219 -0
- package/lib/deps/report.js +37 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +41 -8
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +17 -5
- package/lib/memory/repo-suppress.js +11 -3
- package/lib/references.json +77 -0
- package/lib/remediation/index.js +9 -0
- package/lib/retirejs.js +69 -17
- package/lib/rules/regex-guard.js +368 -0
- package/lib/templates.js +13 -6
- package/lib/tls.js +7 -0
- package/lib/whitebox/frameworks.js +8 -5
- package/lib/whitebox/secrets.js +20 -7
- package/package.json +7 -3
- package/skills/penthera/SKILL.md +23 -5
- package/src/cli/deps.js +60 -5
- package/src/cli/evidence.js +14 -4
- package/src/cli/install.js +14 -5
- package/src/cli/report-outputs.js +25 -2
- package/src/cli/ui.js +89 -0
- package/src/cli.js +19 -7
- package/src/report/audience.js +10 -10
- package/src/report/evidence.js +33 -14
- package/src/report/footer.js +11 -0
- package/src/report/markdown.js +6 -3
- package/src/report/md-escape.js +16 -0
- package/src/reporter.js +1 -0
- package/src/scan-url.js +5 -1
- package/src/ui/api/data.js +309 -0
- package/src/ui/api/demo.js +464 -0
- package/src/ui/api/routes.js +134 -0
- package/src/ui/api/runs.js +243 -0
- package/src/ui/api/summarize.js +451 -0
- package/src/ui/security.js +153 -0
- package/src/ui/server.js +186 -0
- package/src/ui/static/app.css +723 -0
- package/src/ui/static/favicon.svg +1 -0
- package/src/ui/static/index.html +57 -0
- package/src/ui/static/js/api.js +29 -0
- package/src/ui/static/js/app.js +138 -0
- package/src/ui/static/js/charts.js +207 -0
- package/src/ui/static/js/copy.js +79 -0
- package/src/ui/static/js/dom.js +120 -0
- package/src/ui/static/js/format.js +42 -0
- package/src/ui/static/js/theme.js +10 -0
- package/src/ui/static/js/typer.js +99 -0
- package/src/ui/static/js/views/ai.js +68 -0
- package/src/ui/static/js/views/deps.js +96 -0
- package/src/ui/static/js/views/evidence.js +115 -0
- package/src/ui/static/js/views/findings.js +165 -0
- package/src/ui/static/js/views/history.js +92 -0
- package/src/ui/static/js/views/overview.js +184 -0
- package/src/ui/static/js/views/run.js +349 -0
- package/src/ui/static/js/views/settings.js +67 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" shape-rendering="crispEdges"><rect width="8" height="8" fill="#000"/><path d="M1 1h4v1h1v2H5v1H3v2H1zM3 2v2h2V2z" fill="#fff"/><rect x="6" y="6" width="1" height="1" fill="#0000ed"/></svg>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<meta name="color-scheme" content="dark light">
|
|
7
|
+
<meta name="referrer" content="no-referrer">
|
|
8
|
+
<title>penthera ui</title>
|
|
9
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
10
|
+
<script src="/js/theme.js"></script>
|
|
11
|
+
<link rel="stylesheet" href="/app.css">
|
|
12
|
+
<script type="module" src="/js/app.js"></script>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<a class="skip" href="#main">Skip to content</a>
|
|
16
|
+
<header class="menubar">
|
|
17
|
+
<span class="mb-mark">
|
|
18
|
+
<svg width="14" height="16" viewBox="0 0 7 8" aria-hidden="true" shape-rendering="crispEdges"><path class="px" d="M0 0h5v1h1v1h1v2H6v1H5v1H2v2H0zM2 2v2h3V2z"/></svg>
|
|
19
|
+
penthera
|
|
20
|
+
</span>
|
|
21
|
+
<nav aria-label="Screens">
|
|
22
|
+
<ol>
|
|
23
|
+
<li><a href="#/overview" data-view="overview"><span class="key">1</span>Overview</a></li>
|
|
24
|
+
<li><a href="#/findings" data-view="findings"><span class="key">2</span>Findings</a></li>
|
|
25
|
+
<li><a href="#/run" data-view="run"><span class="key">3</span>Run</a></li>
|
|
26
|
+
<li><a href="#/history" data-view="history"><span class="key">4</span>History</a></li>
|
|
27
|
+
<li><a href="#/deps" data-view="deps"><span class="key">5</span>Dependencies</a></li>
|
|
28
|
+
<li><a href="#/ai" data-view="ai"><span class="key">6</span>AI tooling</a></li>
|
|
29
|
+
<li><a href="#/evidence" data-view="evidence"><span class="key">7</span>Evidence</a></li>
|
|
30
|
+
<li><a href="#/settings" data-view="settings"><span class="key">8</span>Settings</a></li>
|
|
31
|
+
</ol>
|
|
32
|
+
</nav>
|
|
33
|
+
<div class="mb-right">
|
|
34
|
+
<span id="mb-chips"></span>
|
|
35
|
+
<button type="button" class="themebtn" id="theme-btn" aria-label="Switch theme">◐ dark</button>
|
|
36
|
+
<span class="mb-clock" id="mb-clock" aria-hidden="true"></span>
|
|
37
|
+
</div>
|
|
38
|
+
</header>
|
|
39
|
+
<div class="desktop">
|
|
40
|
+
<div class="window">
|
|
41
|
+
<div class="titlebar"><span class="box close" aria-hidden="true"></span><span class="title" id="win-title">penthera :: overview</span><span class="box zoom" aria-hidden="true"></span></div>
|
|
42
|
+
<div class="prompt" id="prompt"><span class="user">penthera@127.0.0.1</span>:<span class="path">~/overview</span>$ <span id="prompt-cmd"></span><span class="cursor" aria-hidden="true"></span></div>
|
|
43
|
+
<main class="main" id="main" tabindex="-1">
|
|
44
|
+
<noscript><p>penthera ui needs JavaScript: every screen reads from the local API on this machine.</p></noscript>
|
|
45
|
+
</main>
|
|
46
|
+
<footer class="status">
|
|
47
|
+
<span><kbd>1</kbd>-<kbd>8</kbd> screens</span>
|
|
48
|
+
<span><kbd>/</kbd> filter findings</span>
|
|
49
|
+
<span><kbd>j</kbd> <kbd>k</kbd> move in tables</span>
|
|
50
|
+
<span><kbd>y</kbd> <kbd>n</kbd> <kbd>q</kbd> approve, skip, stop a diff</span>
|
|
51
|
+
<span id="status-bind"></span>
|
|
52
|
+
</footer>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="sr" role="status" aria-live="polite" id="announce"></div>
|
|
56
|
+
</body>
|
|
57
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Same-origin JSON client. The session cookie is HttpOnly and SameSite=Strict,
|
|
2
|
+
// so it rides along on its own; POSTs send JSON so the server's Origin and
|
|
3
|
+
// Content-Type checks pass for this page and fail for every other site.
|
|
4
|
+
|
|
5
|
+
export class ApiError extends Error {
|
|
6
|
+
constructor(message, status) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function call(method, path, body) {
|
|
13
|
+
const res = await fetch(path, {
|
|
14
|
+
method,
|
|
15
|
+
credentials: "same-origin",
|
|
16
|
+
cache: "no-store",
|
|
17
|
+
headers: body === undefined ? { Accept: "application/json" } : { Accept: "application/json", "Content-Type": "application/json" },
|
|
18
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
19
|
+
});
|
|
20
|
+
let data = null;
|
|
21
|
+
try { data = await res.json(); } catch { /* empty or not JSON */ }
|
|
22
|
+
if (!res.ok) throw new ApiError(data?.error || `${res.status} ${res.statusText}`, res.status);
|
|
23
|
+
return data;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const api = {
|
|
27
|
+
get: (path) => call("GET", path),
|
|
28
|
+
post: (path, body = {}) => call("POST", path, body),
|
|
29
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// penthera ui: hash router, keyboard shortcuts, the menu bar and window chrome.
|
|
2
|
+
import { h, clear, announce } from "./dom.js";
|
|
3
|
+
import { api } from "./api.js";
|
|
4
|
+
import { typeInto } from "./typer.js";
|
|
5
|
+
import { patternDefs } from "./charts.js";
|
|
6
|
+
import { SCREENS, ERR } from "./copy.js";
|
|
7
|
+
import * as overview from "./views/overview.js";
|
|
8
|
+
import * as findings from "./views/findings.js";
|
|
9
|
+
import * as run from "./views/run.js";
|
|
10
|
+
import * as history from "./views/history.js";
|
|
11
|
+
import * as deps from "./views/deps.js";
|
|
12
|
+
import * as ai from "./views/ai.js";
|
|
13
|
+
import * as evidence from "./views/evidence.js";
|
|
14
|
+
import * as settings from "./views/settings.js";
|
|
15
|
+
|
|
16
|
+
const VIEWS = { overview, findings, run, history, deps, ai, evidence, settings };
|
|
17
|
+
const ORDER = Object.keys(VIEWS);
|
|
18
|
+
const THEME_KEY = "penthera-ui-theme";
|
|
19
|
+
|
|
20
|
+
const main = document.getElementById("main");
|
|
21
|
+
const ctx = { api, meta: null, go, params: new URLSearchParams(), theme: { get: getTheme, set: setTheme } };
|
|
22
|
+
let cleanup = null;
|
|
23
|
+
let renderSeq = 0;
|
|
24
|
+
|
|
25
|
+
function parseHash() {
|
|
26
|
+
const raw = location.hash.replace(/^#\/?/, "");
|
|
27
|
+
const [name, query = ""] = raw.split("?");
|
|
28
|
+
const alias = name === "agent" ? "run" : name;
|
|
29
|
+
return { name: VIEWS[alias] ? alias : "overview", params: new URLSearchParams(query) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function go(name, params = {}) {
|
|
33
|
+
const q = new URLSearchParams(params).toString();
|
|
34
|
+
const next = `#/${name}${q ? `?${q}` : ""}`;
|
|
35
|
+
if (location.hash === next) render();
|
|
36
|
+
else location.hash = next;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Theme: one system, [data-theme] on <html>. theme.js set it before paint. ──
|
|
40
|
+
function getTheme() { return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"; }
|
|
41
|
+
function setTheme(t) {
|
|
42
|
+
document.documentElement.setAttribute("data-theme", t);
|
|
43
|
+
try { localStorage.setItem(THEME_KEY, t); } catch { /* storage off: the choice lasts this page only */ }
|
|
44
|
+
paintThemeButton();
|
|
45
|
+
announce(`${t === "light" ? "Light" : "Dark"} theme`);
|
|
46
|
+
}
|
|
47
|
+
function paintThemeButton() {
|
|
48
|
+
const b = document.getElementById("theme-btn");
|
|
49
|
+
const t = getTheme();
|
|
50
|
+
b.textContent = t === "light" ? "◑ light" : "◐ dark";
|
|
51
|
+
b.setAttribute("aria-label", `Theme: ${t}. Switch to ${t === "light" ? "dark" : "light"}`);
|
|
52
|
+
}
|
|
53
|
+
document.getElementById("theme-btn").addEventListener("click", () => setTheme(getTheme() === "light" ? "dark" : "light"));
|
|
54
|
+
paintThemeButton();
|
|
55
|
+
|
|
56
|
+
function clock() {
|
|
57
|
+
const d = new Date();
|
|
58
|
+
document.getElementById("mb-clock").textContent = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
59
|
+
}
|
|
60
|
+
clock();
|
|
61
|
+
setInterval(clock, 15_000);
|
|
62
|
+
|
|
63
|
+
async function render() {
|
|
64
|
+
const seq = ++renderSeq;
|
|
65
|
+
const { name, params } = parseHash();
|
|
66
|
+
ctx.params = params;
|
|
67
|
+
for (const a of document.querySelectorAll(".menubar nav a")) {
|
|
68
|
+
if (a.dataset.view === name) a.setAttribute("aria-current", "page");
|
|
69
|
+
else a.removeAttribute("aria-current");
|
|
70
|
+
}
|
|
71
|
+
const title = SCREENS[name].title;
|
|
72
|
+
document.title = `${title} :: penthera ui`;
|
|
73
|
+
document.getElementById("win-title").textContent = `penthera :: ${title.toLowerCase()}`;
|
|
74
|
+
document.querySelector("#prompt .path").textContent = `~/${name}`;
|
|
75
|
+
typeInto(document.getElementById("prompt-cmd"), `penthera ui --view ${name}`);
|
|
76
|
+
|
|
77
|
+
if (cleanup) { try { cleanup(); } catch { /* view already gone */ } cleanup = null; }
|
|
78
|
+
clear(main);
|
|
79
|
+
const root = h("div"); // no enter animation: screens switch from the keyboard (1-8)
|
|
80
|
+
main.append(root);
|
|
81
|
+
try {
|
|
82
|
+
const c = await VIEWS[name].render(root, ctx);
|
|
83
|
+
if (seq !== renderSeq) { c?.(); return; }
|
|
84
|
+
cleanup = c || null;
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (seq !== renderSeq) return;
|
|
87
|
+
clear(root).append(
|
|
88
|
+
h("h1", {}, title),
|
|
89
|
+
h("p", { class: "error", role: "alert" }, e.message),
|
|
90
|
+
e.status === 401 ? h("p", { class: "muted" }, ERR.session) : null,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
announce(`${title} screen`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function chrome(meta) {
|
|
97
|
+
// Only real elements go into the bar: a null child would render as the text "null".
|
|
98
|
+
const chips = [
|
|
99
|
+
meta.demo ? h("span", { class: "chip inv", title: "Fake data seeded by --demo" }, "DEMO DATA") : null,
|
|
100
|
+
h("span", { class: meta.settings.offline ? "chip on" : "chip" }, meta.settings.offline ? "offline" : "online"),
|
|
101
|
+
].filter(Boolean);
|
|
102
|
+
clear(document.getElementById("mb-chips")).append(...chips);
|
|
103
|
+
document.getElementById("status-bind").textContent = `v${meta.version} :: bound to ${location.host} :: no network :: no telemetry`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isTyping(e) {
|
|
107
|
+
const t = e.target;
|
|
108
|
+
return t instanceof HTMLElement && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
document.addEventListener("keydown", (e) => {
|
|
112
|
+
if (e.metaKey || e.ctrlKey || e.altKey || isTyping(e)) return;
|
|
113
|
+
const n = Number(e.key);
|
|
114
|
+
if (Number.isInteger(n) && n >= 1 && n <= ORDER.length) {
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
go(ORDER[n - 1]);
|
|
117
|
+
main.focus({ preventScroll: true });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
window.addEventListener("hashchange", render);
|
|
122
|
+
|
|
123
|
+
(async function boot() {
|
|
124
|
+
document.body.append(patternDefs());
|
|
125
|
+
try {
|
|
126
|
+
ctx.meta = await api.get("/api/meta");
|
|
127
|
+
chrome(ctx.meta);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
clear(main).append(
|
|
130
|
+
h("h1", {}, "Not signed in"),
|
|
131
|
+
h("p", { class: "error", role: "alert" }, e.message),
|
|
132
|
+
h("p", { class: "muted" }, "Run penthera ui and open the one-time link it prints. The link works once."),
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
ctx.refreshMeta = async () => { ctx.meta = await api.get("/api/meta"); chrome(ctx.meta); };
|
|
137
|
+
render();
|
|
138
|
+
})();
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Hand-rolled 1-bit charts: shade characters (█ ▓ ▒ ░) for bars, small SVGs
|
|
2
|
+
// for lines and columns, filled with patterns instead of colours. Every chart
|
|
3
|
+
// carries its numbers as text, so no pattern or colour is the only channel.
|
|
4
|
+
import { h, s } from "./dom.js";
|
|
5
|
+
import { SEVERITIES, stamp } from "./format.js";
|
|
6
|
+
|
|
7
|
+
/** The shade each severity is drawn with: solid, dense, half, sparse, dotted. */
|
|
8
|
+
export const SHADE = { critical: "█", high: "▓", medium: "▒", low: "░", info: "·" };
|
|
9
|
+
|
|
10
|
+
/** One hidden <svg> holding the fill patterns the charts reference by id. */
|
|
11
|
+
export function patternDefs() {
|
|
12
|
+
const hatch = s("pattern", { id: "pt-hatch", width: 6, height: 6, patternUnits: "userSpaceOnUse", patternTransform: "rotate(45)" },
|
|
13
|
+
s("line", { class: "hatch-line", x1: 0, y1: 0, x2: 0, y2: 6 }));
|
|
14
|
+
const dither = s("pattern", { id: "pt-dither", width: 2, height: 2, patternUnits: "userSpaceOnUse" },
|
|
15
|
+
s("rect", { class: "dither-dot", x: 0, y: 0, width: 1, height: 1 }));
|
|
16
|
+
const hatchInk = s("pattern", { id: "pt-hatch-ink", width: 4, height: 4, patternUnits: "userSpaceOnUse", patternTransform: "rotate(45)" },
|
|
17
|
+
s("line", { class: "hatch-ink", x1: 0, y1: 0, x2: 0, y2: 4 }));
|
|
18
|
+
return s("svg", { width: 0, height: 0, "aria-hidden": "true", focusable: "false", class: "sr" }, s("defs", {}, hatch, dither, hatchInk));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A bar of `width` characters filled to value/max with `ch`, the rest dotted. */
|
|
22
|
+
export function textBar(value, max, width = 24, ch = "█") {
|
|
23
|
+
const n = value > 0 && max > 0 ? Math.max(1, Math.round(Math.min(1, value / max) * width)) : 0;
|
|
24
|
+
return { filled: ch.repeat(n), rest: "·".repeat(Math.max(0, width - n)) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Rows of label / bar / count. */
|
|
28
|
+
export function bars(rows, { width = 24 } = {}) {
|
|
29
|
+
const max = Math.max(1, ...rows.map((r) => r.value));
|
|
30
|
+
const el = h("div", { class: "bars", role: "list" });
|
|
31
|
+
for (const r of rows) {
|
|
32
|
+
const { filled, rest } = textBar(r.value, max, width, r.ch || "█");
|
|
33
|
+
el.append(
|
|
34
|
+
h("span", { class: r.cls || "t1", role: "listitem", "aria-label": `${r.label}: ${r.value}` }, r.label),
|
|
35
|
+
h("span", { class: "bar", "aria-hidden": "true" }, h("span", { class: "shade" }, filled), h("span", { class: "shade track" }, rest)),
|
|
36
|
+
h("span", { class: "num t1", "aria-hidden": "true" }, String(r.value)),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return el;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function severityBars(counts, opts = {}) {
|
|
43
|
+
return bars(SEVERITIES.map((sev) => ({ label: sev, value: counts?.[sev] || 0, cls: `sev sev-${sev}`, ch: SHADE[sev] })), opts);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One stacked bar across all severities, as shade characters. */
|
|
47
|
+
export function stackedBar(counts, { width = 40 } = {}) {
|
|
48
|
+
const total = SEVERITIES.reduce((n, k) => n + (counts?.[k] || 0), 0);
|
|
49
|
+
let out = "";
|
|
50
|
+
if (total) {
|
|
51
|
+
for (const k of SEVERITIES) {
|
|
52
|
+
const c = counts?.[k] || 0;
|
|
53
|
+
if (c) out += SHADE[k].repeat(Math.max(1, Math.round((c / total) * width)));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
out = out.slice(0, width).padEnd(width, " ");
|
|
57
|
+
return h("div", { class: "stackbar", role: "img", "aria-label": SEVERITIES.map((k) => `${counts?.[k] || 0} ${k}`).join(", ") }, out);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Tiny line, last point marked. */
|
|
61
|
+
export function sparkline(values, { width = 140, height = 24, label = "trend", zero = true } = {}) {
|
|
62
|
+
const vs = values.filter((v) => Number.isFinite(v));
|
|
63
|
+
const svg = s("svg", { class: "spark", viewBox: `0 0 ${width} ${height}`, role: "img", "aria-label": vs.length ? `${label}: ${vs.join(", ")}` : `${label}: no data` });
|
|
64
|
+
if (vs.length < 2) {
|
|
65
|
+
svg.append(s("line", { class: "grid-line", x1: 0, y1: height / 2, x2: width, y2: height / 2 }));
|
|
66
|
+
return svg;
|
|
67
|
+
}
|
|
68
|
+
const max = Math.max(...vs, 1);
|
|
69
|
+
const min = zero ? Math.min(...vs, 0) : Math.min(...vs) - 2;
|
|
70
|
+
const pad = 3;
|
|
71
|
+
const pts = vs.map((v, i) => [pad + (i * (width - pad * 2)) / (vs.length - 1), height - pad - ((v - min) / (max - min || 1)) * (height - pad * 2)]);
|
|
72
|
+
svg.append(s("polyline", { class: "s-line", points: pts.map((p) => p.map((n) => n.toFixed(1)).join(",")).join(" ") }));
|
|
73
|
+
const last = pts[pts.length - 1];
|
|
74
|
+
svg.append(s("rect", { class: "s-dot", x: last[0] - 2, y: last[1] - 2, width: 4, height: 4 }));
|
|
75
|
+
return svg;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Findings over time: open (blue line over a hatched area) and critical+high
|
|
80
|
+
* (dashed ink).
|
|
81
|
+
* @param {{ ts: string, total: number, severe: number }[]} points
|
|
82
|
+
*/
|
|
83
|
+
export function trendChart(points, { width = 640, height = 180 } = {}) {
|
|
84
|
+
const m = { l: 30, r: 10, t: 10, b: 22 };
|
|
85
|
+
const iw = width - m.l - m.r;
|
|
86
|
+
const ih = height - m.t - m.b;
|
|
87
|
+
const max = Math.max(1, ...points.map((p) => p.total));
|
|
88
|
+
const nice = Math.ceil(max / 5) * 5 || 5;
|
|
89
|
+
const x = (i) => m.l + (points.length < 2 ? iw / 2 : (i * iw) / (points.length - 1));
|
|
90
|
+
const y = (v) => m.t + ih - (v / nice) * ih;
|
|
91
|
+
const svg = s("svg", {
|
|
92
|
+
class: "chart", viewBox: `0 0 ${width} ${height}`, role: "img",
|
|
93
|
+
"aria-label": points.length
|
|
94
|
+
? `Open findings over ${points.length} scans: from ${points[0].total} to ${points[points.length - 1].total}; critical and high from ${points[0].severe} to ${points[points.length - 1].severe}`
|
|
95
|
+
: "No scans yet",
|
|
96
|
+
});
|
|
97
|
+
for (let i = 0; i <= 5; i++) {
|
|
98
|
+
const v = (nice / 5) * i;
|
|
99
|
+
svg.append(s("line", { class: i ? "grid-line" : "axis", x1: m.l, x2: width - m.r, y1: y(v), y2: y(v) }));
|
|
100
|
+
svg.append(s("text", { class: "tick", x: m.l - 6, y: y(v) + 3, "text-anchor": "end" }, String(Math.round(v))));
|
|
101
|
+
}
|
|
102
|
+
if (!points.length) return svg;
|
|
103
|
+
const line = (key) => points.map((p, i) => `${i ? "L" : "M"}${x(i).toFixed(1)},${y(p[key]).toFixed(1)}`).join("");
|
|
104
|
+
svg.append(s("path", { class: "area-total", d: `${line("total")}L${x(points.length - 1).toFixed(1)},${y(0)}L${x(0).toFixed(1)},${y(0)}Z` }));
|
|
105
|
+
svg.append(s("path", { class: "line-total", d: line("total") }));
|
|
106
|
+
svg.append(s("path", { class: "line-severe", d: line("severe") }));
|
|
107
|
+
points.forEach((p, i) => {
|
|
108
|
+
svg.append(s("rect", { class: "dot", x: x(i) - 3, y: y(p.total) - 3, width: 6, height: 6 }, s("title", {}, `${stamp(p.ts)}: ${p.total} open, ${p.severe} critical/high`)));
|
|
109
|
+
});
|
|
110
|
+
const ticks = points.length <= 6 ? points.map((_, i) => i) : [0, Math.floor((points.length - 1) / 2), points.length - 1];
|
|
111
|
+
for (const i of ticks) {
|
|
112
|
+
svg.append(s("text", { class: "tick", x: x(i), y: height - 6, "text-anchor": i === 0 ? "start" : i === points.length - 1 ? "end" : "middle" }, stamp(points[i].ts).slice(5, 10)));
|
|
113
|
+
}
|
|
114
|
+
return svg;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Vertical columns, dither-filled, with value labels. */
|
|
118
|
+
export function columns(items, { width = 320, height = 130, label = "distribution" } = {}) {
|
|
119
|
+
const m = { t: 16, b: 20 };
|
|
120
|
+
const max = Math.max(1, ...items.map((i) => i.n));
|
|
121
|
+
const bw = width / items.length;
|
|
122
|
+
const svg = s("svg", { class: "chart", viewBox: `0 0 ${width} ${height}`, role: "img", "aria-label": `${label}: ${items.map((i) => `${i.label} ${i.n}`).join(", ")}` });
|
|
123
|
+
svg.append(s("line", { class: "axis", x1: 0, x2: width, y1: height - m.b, y2: height - m.b }));
|
|
124
|
+
items.forEach((it, i) => {
|
|
125
|
+
const hgt = ((height - m.t - m.b) * it.n) / max;
|
|
126
|
+
const x0 = i * bw + bw * 0.22;
|
|
127
|
+
if (it.n) svg.append(s("rect", { class: "col", x: x0, y: height - m.b - hgt, width: bw * 0.56, height: hgt }));
|
|
128
|
+
svg.append(s("text", { class: "col-value", x: i * bw + bw / 2, y: height - m.b - hgt - 4, "text-anchor": "middle" }, String(it.n)));
|
|
129
|
+
svg.append(s("text", { class: "col-label", x: i * bw + bw / 2, y: height - 5, "text-anchor": "middle" }, it.label));
|
|
130
|
+
});
|
|
131
|
+
return svg;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const BIG = {
|
|
135
|
+
A: [" ▄▀▀▄ ", "█▄▄▄█", "█ █"],
|
|
136
|
+
B: ["█▀▀▄", "█▀▀▄", "█▄▄▀"],
|
|
137
|
+
C: ["▄▀▀▀", "█ ", "▀▄▄▄"],
|
|
138
|
+
D: ["█▀▀▄", "█ █", "█▄▄▀"],
|
|
139
|
+
F: ["█▀▀▀", "█▀▀ ", "█ "],
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export function bigGrade(letter) {
|
|
143
|
+
const rows = BIG[letter] || ["", letter || "?", ""];
|
|
144
|
+
return h("pre", { class: `grade-big grade-${letter}`, "aria-hidden": "true" }, rows.join("\n"));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The CLI's wordmark (src/cli/ansi.js LOGO_BIG), ANSI Shadow block letters.
|
|
148
|
+
const LOGO = [
|
|
149
|
+
"██████╗ ███████╗███╗ ██╗████████╗██╗ ██╗███████╗██████╗ █████╗ ",
|
|
150
|
+
"██╔══██╗██╔════╝████╗ ██║╚══██╔══╝██║ ██║██╔════╝██╔══██╗██╔══██╗",
|
|
151
|
+
"██████╔╝█████╗ ██╔██╗ ██║ ██║ ███████║█████╗ ██████╔╝███████║",
|
|
152
|
+
"██╔═══╝ ██╔══╝ ██║╚██╗██║ ██║ ██╔══██║██╔══╝ ██╔══██╗██╔══██║",
|
|
153
|
+
"██║ ███████╗██║ ╚████║ ██║ ██║ ██║███████╗██║ ██║██║ ██║",
|
|
154
|
+
"╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝",
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
/** The wordmark: solid blocks in #0000ed, the box-drawing shadow in a second tone. */
|
|
158
|
+
export function wordmark() {
|
|
159
|
+
const pre = h("pre", { class: "wordmark", "aria-hidden": "true" });
|
|
160
|
+
for (const row of LOGO) {
|
|
161
|
+
for (const run of row.match(/█+|[^█]+/g)) pre.append(h("span", { class: run[0] === "█" ? "fill" : "edge" }, run));
|
|
162
|
+
pre.append("\n");
|
|
163
|
+
}
|
|
164
|
+
return pre;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Finding lifetimes as a Gantt chart: one lane per finding, a bar from the
|
|
169
|
+
* scan it first appeared in to the last one it was seen in. Open findings are
|
|
170
|
+
* hatched up to "now", critical ones solid, fixed ones an empty outline.
|
|
171
|
+
* Every scan is a dashed line with a black chip (S1, S2, ...) on top.
|
|
172
|
+
* @param {{ title: string, severity: string, from: number, to: number, open: boolean }[]} rows
|
|
173
|
+
* @param {{ ts: string }[]} scans
|
|
174
|
+
*/
|
|
175
|
+
export function ganttChart(rows, scans, { width = 1100 } = {}) {
|
|
176
|
+
const lane = 24;
|
|
177
|
+
const m = { l: 290, r: 16, t: 46, b: 10 };
|
|
178
|
+
const height = m.t + rows.length * lane + m.b;
|
|
179
|
+
const n = Math.max(1, scans.length - 1);
|
|
180
|
+
const iw = width - m.l - m.r;
|
|
181
|
+
const x = (i) => m.l + (i / n) * iw * 0.94;
|
|
182
|
+
const nowX = m.l + iw;
|
|
183
|
+
const svg = s("svg", { class: "chart gantt", viewBox: `0 0 ${width} ${height}`, role: "img",
|
|
184
|
+
"aria-label": `${rows.length} findings across ${scans.length} scans: ${rows.filter((r) => r.open).length} still open, ${rows.filter((r) => !r.open).length} fixed` });
|
|
185
|
+
// Axis: scan chips and dashed lines.
|
|
186
|
+
svg.append(s("line", { class: "axis", x1: m.l, x2: width - m.r, y1: m.t - 6, y2: m.t - 6 }));
|
|
187
|
+
scans.forEach((sc, i) => {
|
|
188
|
+
const cx = x(i);
|
|
189
|
+
svg.append(s("line", { class: "scan-line", x1: cx, x2: cx, y1: m.t - 6, y2: height - m.b }));
|
|
190
|
+
svg.append(s("rect", { class: "chip", x: cx - 13, y: 2, width: 26, height: 15 }));
|
|
191
|
+
svg.append(s("text", { class: "chip-t", x: cx, y: 13, "text-anchor": "middle" }, `S${i + 1}`));
|
|
192
|
+
svg.append(s("text", { class: "axis-t", x: cx, y: m.t - 12, "text-anchor": "middle" }, stamp(sc.ts).slice(5, 10)));
|
|
193
|
+
});
|
|
194
|
+
svg.append(s("line", { class: "now-line", x1: nowX, x2: nowX, y1: m.t - 14, y2: height - m.b }));
|
|
195
|
+
svg.append(s("path", { class: "chip", d: `M${nowX - 5},${m.t - 20}h10l-5,7z` }));
|
|
196
|
+
svg.append(s("text", { class: "axis-t", x: nowX - 8, y: m.t - 24, "text-anchor": "end" }, "now"));
|
|
197
|
+
rows.forEach((r, i) => {
|
|
198
|
+
const y = m.t + i * lane;
|
|
199
|
+
const label = r.title.length > 34 ? `${r.title.slice(0, 33)}…` : r.title;
|
|
200
|
+
svg.append(s("text", { class: "lane-code", x: 0, y: y + 15 }, ({ critical: "C+", high: "H+", medium: "M+", low: "L+" })[r.severity] || "I+"));
|
|
201
|
+
svg.append(s("text", { class: ["lane-label", r.open ? "b" : null].filter(Boolean).join(" "), x: 28, y: y + 15 }, s("title", {}, r.title), label));
|
|
202
|
+
const x0 = x(r.from);
|
|
203
|
+
const x1 = r.open ? nowX : Math.max(x(r.to) + 10, x0 + 10);
|
|
204
|
+
svg.append(s("rect", { class: `bar${r.open ? " open" : ""}${r.open && r.severity === "critical" ? " crit" : ""}`, x: x0, y: y + 5, width: x1 - x0, height: 13 }));
|
|
205
|
+
});
|
|
206
|
+
return svg;
|
|
207
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Every user-facing sentence in one place, so the voice can be read and
|
|
2
|
+
// proofed as a whole. Rules (voice.md): sentence case, "::" as connective,
|
|
3
|
+
// no exclamation marks, verbs first on buttons, empty states name the
|
|
4
|
+
// action that fills them, errors give the cause then the fix.
|
|
5
|
+
|
|
6
|
+
export const TAGLINE = "Open-source security for the apps and AI systems you ship"; // README headline; display lines take no period
|
|
7
|
+
|
|
8
|
+
export const SCREENS = {
|
|
9
|
+
overview: { title: "Overview", lede: "Where each project stands, from the reports and memory on this machine." },
|
|
10
|
+
findings: { title: "Findings", lede: "Every open finding in the latest report per project. Pick one to see what it means and how to fix it." },
|
|
11
|
+
run: { title: "Run", lede: "Scan, check dependencies, audit AI tooling, or let the agent fix things. Every diff waits for you. Nothing is pushed." },
|
|
12
|
+
history: { title: "History", lede: "Compare two scans of one project :: what is new, what got fixed, what is still open." },
|
|
13
|
+
deps: { title: "Dependencies", lede: "What your npm and Python packages can do, what changed, and what the install firewall let in." },
|
|
14
|
+
ai: { title: "AI tooling", lede: "Skills, MCP servers, hooks and rule files :: the grade, hidden content, and drift against penthera.lock." },
|
|
15
|
+
evidence: { title: "Evidence", lede: "Hashed, signable bundles for an auditor. Create one from the latest scan, verify any of them in one click." },
|
|
16
|
+
settings: { title: "Settings", lede: "For this session. Nothing here is written to disk except your theme choice." },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const START = {
|
|
20
|
+
welcome: "Welcome",
|
|
21
|
+
intro: "No reports yet. Start with a scan of something you own :: your localhost, a preview URL, or this repo. Each step writes a report into the reports folder, and this screen fills in.",
|
|
22
|
+
gate: "Only scan apps you own or have written permission to test.",
|
|
23
|
+
steps: [
|
|
24
|
+
{ title: "Scan your app", body: "TLS, headers, cookies, CORS, auth, exposed APIs and leaked secrets. About a minute.", kind: "scan", action: "Scan app" },
|
|
25
|
+
{ title: "Check your dependencies", body: "What every npm package in the lockfile can do: install scripts, shell, network, obfuscated code.", kind: "deps", action: "Check dependencies" },
|
|
26
|
+
{ title: "Audit your AI tooling", body: "Skills, MCP servers, hooks and rule files: hidden instructions, unpinned servers, wide permissions.", kind: "agents", action: "Audit AI tooling" },
|
|
27
|
+
],
|
|
28
|
+
demo: "Want to look around first? Stop this server and run",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const NEXT = {
|
|
32
|
+
heading: "What next?",
|
|
33
|
+
overview: [
|
|
34
|
+
{ label: "Read the most serious finding", why: "what it means, how sure we are, the fix", go: ["findings"] },
|
|
35
|
+
{ label: "Fix what can be fixed", why: "the agent proposes diffs, you approve each one", go: ["run", { kind: "loop" }] },
|
|
36
|
+
{ label: "Compare with the last scan", why: "new, fixed, still open", go: ["history"] },
|
|
37
|
+
{ label: "Create evidence for an auditor", why: "a hashed bundle plus the auditor report", go: ["evidence"] },
|
|
38
|
+
],
|
|
39
|
+
scan: [
|
|
40
|
+
{ label: "Read the findings", why: "start with the most serious one", go: ["findings"] },
|
|
41
|
+
{ label: "Fix them with the agent", why: "you approve every diff", go: ["run", { kind: "loop" }] },
|
|
42
|
+
{ label: "Compare with the previous scan", go: ["history"] },
|
|
43
|
+
],
|
|
44
|
+
loop: [
|
|
45
|
+
{ label: "Compare before and after", why: "what the loop fixed", go: ["history"] },
|
|
46
|
+
{ label: "Create evidence for an auditor", go: ["evidence"] },
|
|
47
|
+
{ label: "Back to the overview", go: ["overview"] },
|
|
48
|
+
],
|
|
49
|
+
deps: [
|
|
50
|
+
{ label: "See the capability diff", go: ["deps"] },
|
|
51
|
+
{ label: "Back to the overview", go: ["overview"] },
|
|
52
|
+
],
|
|
53
|
+
agents: [
|
|
54
|
+
{ label: "See the grade and hidden content", go: ["ai"] },
|
|
55
|
+
{ label: "Back to the overview", go: ["overview"] },
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const RUN_KINDS = [
|
|
60
|
+
{ kind: "scan", label: "Scan app", needs: "url-or-folder", button: "Run scan" },
|
|
61
|
+
{ kind: "deps", label: "Check dependencies", needs: "folder", button: "Check dependencies" },
|
|
62
|
+
{ kind: "agents", label: "Audit AI tooling", needs: "folder", button: "Audit AI tooling" },
|
|
63
|
+
{ kind: "loop", label: "Fix loop", needs: "folder", button: "Run as agent" },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export const EMPTY = {
|
|
67
|
+
findingsNone: "No findings in the reports folder yet. Run a scan from Run, or write one with penthera <url> -o reports/scan.json.",
|
|
68
|
+
findingsFiltered: (which) => `Nothing matches :: ${which} hide${which.includes(",") ? "" : "s"} the rest. Clear them to see every finding.`,
|
|
69
|
+
history: "No project has two scans yet. Scan the same target again and the comparison appears here.",
|
|
70
|
+
deps: "No dependency report yet. Run Check dependencies, or penthera deps -o reports/deps.json.",
|
|
71
|
+
ai: "No AI tooling report yet. Run Audit AI tooling, or penthera agents . -o reports/agents.json.",
|
|
72
|
+
evidence: "No bundles yet. Create one from the latest scan of a project above.",
|
|
73
|
+
memory: "No memory yet. The agent loop and scans with --memory write it to ~/.penthera/memory.",
|
|
74
|
+
pastRuns: "No loop runs in the reports folder yet.",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const ERR = {
|
|
78
|
+
session: "The session ended. Restart penthera ui and open the new one-time link it prints.",
|
|
79
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// DOM helpers. Everything is built with createElement and textContent: report
|
|
2
|
+
// data (finding titles, evidence, diffs) comes from scanned sites and repos,
|
|
3
|
+
// so it is never parsed as HTML. No HTML string is ever assigned anywhere in the UI.
|
|
4
|
+
|
|
5
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
6
|
+
|
|
7
|
+
function apply(el, attrs) {
|
|
8
|
+
for (const [k, v] of Object.entries(attrs || {})) {
|
|
9
|
+
if (v == null || v === false) continue;
|
|
10
|
+
if (k === "class") el.setAttribute("class", Array.isArray(v) ? v.filter(Boolean).join(" ") : v);
|
|
11
|
+
else if (k === "text") el.textContent = v;
|
|
12
|
+
else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
|
|
13
|
+
else if (k === "dataset") Object.assign(el.dataset, v);
|
|
14
|
+
else el.setAttribute(k, v === true ? "" : String(v));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function append(el, children) {
|
|
19
|
+
for (const c of children.flat(Infinity)) {
|
|
20
|
+
if (c == null || c === false) continue;
|
|
21
|
+
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** h("div", { class: "x", onclick }, child, "text") */
|
|
26
|
+
export function h(tag, attrs, ...children) {
|
|
27
|
+
const el = document.createElement(tag);
|
|
28
|
+
apply(el, attrs);
|
|
29
|
+
append(el, children);
|
|
30
|
+
return el;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** SVG element. */
|
|
34
|
+
export function s(tag, attrs, ...children) {
|
|
35
|
+
const el = document.createElementNS(SVG_NS, tag);
|
|
36
|
+
apply(el, attrs);
|
|
37
|
+
append(el, children);
|
|
38
|
+
return el;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Empty an element. Returns a null-safe appender: native Element.append()
|
|
43
|
+
* turns null into the text "null", which is how a stray conditional child
|
|
44
|
+
* once rendered "null" in the menu bar. Everything goes through append() below.
|
|
45
|
+
*/
|
|
46
|
+
export function clear(el) {
|
|
47
|
+
while (el.firstChild) el.firstChild.remove();
|
|
48
|
+
return { append: (...children) => { append(el, children); return el; } };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Null-safe append for an existing element. */
|
|
52
|
+
export function put(el, ...children) {
|
|
53
|
+
append(el, children);
|
|
54
|
+
return el;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A section as a window: pinstriped title bar with a close box, the title
|
|
59
|
+
* centred on its plate, optional metadata on the right (attrs.meta).
|
|
60
|
+
*/
|
|
61
|
+
export function panel(title, attrs, ...children) {
|
|
62
|
+
const id = `p-${Math.random().toString(36).slice(2, 9)}`;
|
|
63
|
+
return h("section", { class: ["panel", attrs?.class], "aria-labelledby": id },
|
|
64
|
+
h("div", { class: "panel-bar" },
|
|
65
|
+
h("span", { class: "box", "aria-hidden": "true" }),
|
|
66
|
+
h("h2", { class: "panel-h", id }, title),
|
|
67
|
+
attrs?.meta ? h("span", { class: "panel-meta" }, attrs.meta) : null,
|
|
68
|
+
h("span", { class: "grip", "aria-hidden": "true" }),
|
|
69
|
+
),
|
|
70
|
+
h("div", { class: "panel-body" }, ...children));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const sevClass = (sev) => `sev sev-${sev || "info"}`;
|
|
74
|
+
export const sevBadge = (sev) => h("span", { class: sevClass(sev) }, sev || "info");
|
|
75
|
+
|
|
76
|
+
export function empty(text) {
|
|
77
|
+
return h("p", { class: "empty" }, text);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function announce(text) {
|
|
81
|
+
const el = document.getElementById("announce");
|
|
82
|
+
if (!el) return;
|
|
83
|
+
el.textContent = "";
|
|
84
|
+
setTimeout(() => { el.textContent = text; }, 30);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
88
|
+
|
|
89
|
+
/** Colour a unified diff line by line (still plain text nodes). */
|
|
90
|
+
export function diffBlock(diff) {
|
|
91
|
+
const pre = h("pre", { class: "block diff", tabindex: "0", "aria-label": "Diff preview" });
|
|
92
|
+
for (const line of String(diff || "").split("\n")) {
|
|
93
|
+
const cls = line.startsWith("+++") || line.startsWith("---") ? "meta"
|
|
94
|
+
: line.startsWith("+") ? "add" : line.startsWith("-") ? "del" : line.startsWith("@@") ? "hunk" : null;
|
|
95
|
+
pre.append(h("span", { class: cls }, `${line}\n`));
|
|
96
|
+
}
|
|
97
|
+
return pre;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A figure caption under a chart: what it is, where the numbers came from. */
|
|
101
|
+
export function fig(left, right = "") {
|
|
102
|
+
return h("p", { class: "fig" }, h("span", {}, left), h("span", {}, right));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The CLI's "What next?" block: numbered choices that navigate. */
|
|
106
|
+
export function whatNext(items, go, heading = "What next?") {
|
|
107
|
+
return h("section", { class: "next", "aria-label": heading },
|
|
108
|
+
h("h2", {}, heading),
|
|
109
|
+
h("ol", {}, items.map((it) => h("li", {},
|
|
110
|
+
h("button", { type: "button", onclick: () => go(...it.go) }, it.label),
|
|
111
|
+
it.why ? h("span", { class: "why" }, `:: ${it.why}`) : null,
|
|
112
|
+
))));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A window inside the window: pinstriped title bar, close and zoom boxes. */
|
|
116
|
+
export function subwin(title, attrs, ...children) {
|
|
117
|
+
return h("section", { class: ["subwin", attrs?.class], "aria-label": title },
|
|
118
|
+
h("div", { class: "titlebar" }, h("span", { class: "box close", "aria-hidden": "true" }), h("span", { class: "title" }, title), h("span", { class: "box zoom", "aria-hidden": "true" })),
|
|
119
|
+
...children);
|
|
120
|
+
}
|