diff --git a/docs/images/webui-docs.png b/docs/images/webui-docs.png index 7e101c2..33b42b1 100644 Binary files a/docs/images/webui-docs.png and b/docs/images/webui-docs.png differ diff --git a/docs/images/webui-emitter-light.png b/docs/images/webui-emitter-light.png index 20b2334..20e3671 100644 Binary files a/docs/images/webui-emitter-light.png and b/docs/images/webui-emitter-light.png differ diff --git a/docs/images/webui-emitter.png b/docs/images/webui-emitter.png index eac3d3d..2a9f452 100644 Binary files a/docs/images/webui-emitter.png and b/docs/images/webui-emitter.png differ diff --git a/docs/images/webui-run.png b/docs/images/webui-run.png index d93ccef..4f50093 100644 Binary files a/docs/images/webui-run.png and b/docs/images/webui-run.png differ diff --git a/docs/images/webui-terminal.png b/docs/images/webui-terminal.png index 73d9af8..4742bc5 100644 Binary files a/docs/images/webui-terminal.png and b/docs/images/webui-terminal.png differ diff --git a/scripts/capture-webui-screenshots.py b/scripts/capture-webui-screenshots.py index e901eaa..7ea55a1 100644 --- a/scripts/capture-webui-screenshots.py +++ b/scripts/capture-webui-screenshots.py @@ -171,6 +171,21 @@ def button_by_text(text: str) -> str: ) +def button_starting_with(prefix: str) -> str: + """Match on a stable prefix rather than a whole label. + + The run button used to read "Start run". It now names its destination, so it + is "Run and send to 10.0.20.125:514", "Run and write to file" or "Run without + sending" depending on where the events are going. An exact match silently + stopped working and this script only failed the next time someone ran it, + which was months later. + """ + return ( + "[...document.querySelectorAll('button')]" + f".find(b => b.textContent.trim().startsWith({json.dumps(prefix)}))" + ) + + def shot_name(view: str, theme: str) -> str: """`webui-emitter.png` for dark, `webui-emitter-light.png` for light. @@ -285,7 +300,7 @@ async def capture_all(url: str, theme: str, views: set[str]) -> None: await asyncio.sleep(0.8) # No collector and no output file: the run emits to the browser # stream and writes nothing to disk. - await page.click(button_by_text("Start run")) + await page.click(button_starting_with("Run ")) # Capture just after the plan drains. REP-004's default is 108000 # events and the useful window is narrow: at 2.2s the readout still # showed single digits, and a looser "wait until the rate is high" diff --git a/webui/src/App.tsx b/webui/src/App.tsx index e2eae0c..4bb0aae 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -162,7 +162,7 @@ export default function App() { @@ -139,17 +139,17 @@ export function CatalogTable({ techniques, selectedId, onSelect }: Props) { {sel && ( )} - + {t.name} - + {t.attack[0] ?? ""} - + {t.id} · {logTypeOf(t)} {!t.implemented && ( - + soon )} diff --git a/webui/src/components/ConnectionCard.tsx b/webui/src/components/ConnectionCard.tsx index 4cab2bd..994cc57 100644 --- a/webui/src/components/ConnectionCard.tsx +++ b/webui/src/components/ConnectionCard.tsx @@ -128,8 +128,8 @@ export function ConnectionCard({ return (
- Collector - {BADGE[badge].label} + Collector + {BADGE[badge].label}
Vendor profile
@@ -145,7 +145,7 @@ export function ConnectionCard({ aria-checked={v === vendor} onClick={() => onVendorChange(v)} className={cn( - "h-7 flex-1 rounded-[5px] text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + "h-7 flex-1 whitespace-nowrap rounded-[5px] text-label font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", v === vendor ? "bg-elev text-foreground shadow-sm" : "text-text-3 hover:text-foreground", @@ -163,7 +163,7 @@ export function ConnectionCard({ setHost(e.target.value)} /> @@ -174,7 +174,7 @@ export function ConnectionCard({ setPort(e.target.value)} /> @@ -182,7 +182,7 @@ export function ConnectionCard({
setTlsCafile(e.target.value)} placeholder="/path/to/ca.pem" @@ -221,15 +221,15 @@ export function ConnectionCard({ - cap {epsCap} eps + cap {epsCap} eps
{error && ( -
+
{error}
@@ -239,7 +239,7 @@ export function ConnectionCard({
+
{report.source} -> {report.host}: {report.port} {report.interface ? ( @@ -284,7 +284,7 @@ export function ConnectionCard({ )} {stale && ( -
+
The target changed since the last test. Send a test log to describe this one.
)} diff --git a/webui/src/components/DocsView.tsx b/webui/src/components/DocsView.tsx index 893cd0f..634b1e3 100644 --- a/webui/src/components/DocsView.tsx +++ b/webui/src/components/DocsView.tsx @@ -137,7 +137,7 @@ export function DocsView() { disabled={!page.available} aria-current={page.id === current ? "true" : undefined} className={cn( - "whitespace-nowrap rounded-md px-2.5 py-2 text-left text-[12.5px] transition-colors hover:bg-secondary disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + "whitespace-nowrap rounded-md px-2.5 py-2 text-left text-body transition-colors hover:bg-secondary disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", page.id === current && "bg-secondary font-medium", )} > @@ -148,7 +148,7 @@ export function DocsView() {
{error && ( -
+
{error}
)} diff --git a/webui/src/components/LogsView.tsx b/webui/src/components/LogsView.tsx index ad1a5ba..7079979 100644 --- a/webui/src/components/LogsView.tsx +++ b/webui/src/components/LogsView.tsx @@ -147,7 +147,7 @@ export function LogsView() { title={option.hint} aria-pressed={level === option.id} className={cn( - "rounded-sm border px-2.5 py-1 text-[12px] transition-colors", + "rounded-sm border px-2.5 py-1 text-body transition-colors", level === option.id ? "border-signal/60 bg-signal/10 text-foreground" : "text-muted-foreground hover:text-foreground", @@ -159,7 +159,7 @@ export function LogsView() {
- + {counts.total} line{counts.total === 1 ? "" : "s"} {counts.warnings > 0 && ( · {counts.warnings} warning @@ -186,7 +186,7 @@ export function LogsView() {
{error && ( -

+

{error}

)} @@ -196,7 +196,7 @@ export function LogsView() { onScroll={onScroll} // min-w-0 so one long line scrolls inside this box instead of stretching // the page. See docs/webui-reskin-design.md section 5. - className="min-w-0 flex-1 overflow-auto rounded-sm border bg-card p-2.5 font-mono text-[12px] leading-[1.55]" + className="min-w-0 flex-1 overflow-auto rounded-sm border bg-card p-2.5 font-mono text-data leading-[1.55]" > {entries.length === 0 ? (

@@ -226,7 +226,7 @@ export function LogsView() { const node = bodyRef.current; if (node) node.scrollTop = node.scrollHeight; }} - className="self-center rounded-sm border px-2.5 py-1 text-[12px] text-muted-foreground hover:text-foreground" + className="self-center rounded-sm border px-2.5 py-1 text-body text-muted-foreground hover:text-foreground" > Jump to latest diff --git a/webui/src/components/RunPanel.tsx b/webui/src/components/RunPanel.tsx index 8fa2812..493c3bb 100644 --- a/webui/src/components/RunPanel.tsx +++ b/webui/src/components/RunPanel.tsx @@ -431,7 +431,7 @@ export function RunPanel({ technique, defaultSeed, collector, vendor, epsCap, an

setDuration(e.target.value)} @@ -461,7 +461,7 @@ export function RunPanel({ technique, defaultSeed, collector, vendor, epsCap, an setSeed(e.target.value)} /> @@ -472,7 +472,7 @@ export function RunPanel({ technique, defaultSeed, collector, vendor, epsCap, an {choice === "plan" ? "Plan time" : "Burst"} {projected !== null && ( - + {fmtSpan(projected)} )} @@ -553,26 +553,26 @@ export function RunPanel({ technique, defaultSeed, collector, vendor, epsCap, an setSpeed(e.target.value)} /> - x + x
)}

{paceConsequence(pace, speedNum, preview)}

{!collector && ( -

+

No collector configured. Sends fail closed. Connect one, or write to file.

)} @@ -581,7 +581,7 @@ export function RunPanel({ technique, defaultSeed, collector, vendor, epsCap, an {collector && !sending && !toFile && (
No destination selected. This run will render events and neither send nor write them, and the readout will still show a rate, because it measures rendering. Turn @@ -591,14 +591,14 @@ export function RunPanel({ technique, defaultSeed, collector, vendor, epsCap, an {anchorNotice(anchor, sending, anchorEpoch) && (
{anchorNotice(anchor, sending, anchorEpoch)}
)} {toFile && ( setFilePath(e.target.value)} /> @@ -611,7 +611,7 @@ export function RunPanel({ technique, defaultSeed, collector, vendor, epsCap, an {lockedBy && (
{lockedBy.technique_id ?? "Another run"} is already running, and only one run @@ -622,7 +622,7 @@ export function RunPanel({ technique, defaultSeed, collector, vendor, epsCap, an
{error && ( -
+
{error}
)} @@ -672,11 +672,11 @@ export function RunPanel({ technique, defaultSeed, collector, vendor, epsCap, an {/* live stream */}
Live CEF · {vendor} - tail · last {MAX_VISIBLE} + tail · last {MAX_VISIBLE}
{linesRef.current.length === 0 ? (
@@ -694,7 +694,7 @@ export function RunPanel({ technique, defaultSeed, collector, vendor, epsCap, an {/* manifest */} {manifest && (
-
+
@@ -714,14 +714,14 @@ export function RunPanel({ technique, defaultSeed, collector, vendor, epsCap, an ].map(([k, v]) => (
{k} - + {String(v)}
))}
{manifest.warmup_note && ( -
note: {manifest.warmup_note}
+
note: {manifest.warmup_note}
)}
)} diff --git a/webui/src/components/SignalReadout.tsx b/webui/src/components/SignalReadout.tsx index 1b07009..908e7c6 100644 --- a/webui/src/components/SignalReadout.tsx +++ b/webui/src/components/SignalReadout.tsx @@ -71,19 +71,19 @@ export function SignalReadout({
- + {eps} - events / sec + events / sec {running && ( - + emitting )}
-
+
elapsed {elapsedLabel} diff --git a/webui/src/components/TechniqueDetail.tsx b/webui/src/components/TechniqueDetail.tsx index 146ba95..7a579e9 100644 --- a/webui/src/components/TechniqueDetail.tsx +++ b/webui/src/components/TechniqueDetail.tsx @@ -35,8 +35,8 @@ function Chip({ label, signal }: { label: string; signal?: boolean }) { {label} @@ -61,8 +61,8 @@ function Card({ title, children }: { title: string; children: ReactNode }) { function Field({ k, v }: { k: string; v: string }) { return (
- {k} - {v} + {k} + {v}
); } @@ -84,7 +84,7 @@ function SampleLines({ technique, vendor }: Props) { }, [technique.id, vendor]); return ( -
+
{err ? ( sample unavailable: {err} ) : !sample ? ( @@ -119,10 +119,10 @@ export function TechniqueDetail({ technique, vendor }: Props) { return (
{/* identity */} -
+
{technique.id} · {technique.ndr_uc}
-

{technique.name}

+

{technique.name}

{/* The objective, first and in the reading colour. This slot used to hold "Emits synthetic telemetry that @@ -133,18 +133,18 @@ export function TechniqueDetail({ technique, vendor }: Props) { {technique.objective && (

{technique.objective}

)} -

+

Emits synthetic {technique.log_type}:{technique.subtype}{" "} telemetry that exercises {technique.ndr_rule}.

{technique.tactics.map((t) => ( - + {t} ))} @@ -162,23 +162,23 @@ export function TechniqueDetail({ technique, vendor }: Props) { {/* detail cards */}
-

+

The rule keys on the fields that move against a steady baseline.

-
Signal (varied)
+
Signal (varied)
{technique.cef_fields_varied.map((f) => ( ))}
-
Held constant
+
Held constant
{technique.cef_fields_held.map((f) => ( ))}
{technique.benign_baseline && ( -

+

Baseline · {technique.benign_baseline}

@@ -194,11 +194,11 @@ export function TechniqueDetail({ technique, vendor }: Props) { {paramKeys.length > 0 && (
-
+
Intensity presets
- +
@@ -230,7 +230,7 @@ export function TechniqueDetail({ technique, vendor }: Props) {
What the logs will show · {vendorLabel(vendor)}
- + {technique.log_type}:{technique.subtype} · sig {technique.signature_id}
@@ -238,7 +238,7 @@ export function TechniqueDetail({ technique, vendor }: Props) { {distEntries.length > 0 && (
{distEntries.map(([k, v]) => ( -
+
{k} {fmt(v)}
@@ -249,7 +249,7 @@ export function TechniqueDetail({ technique, vendor }: Props) {
{(technique.references.length > 0 || technique.safety_notes) && ( -
+
{technique.references.length > 0 && (
Refs · diff --git a/webui/src/components/TechniqueDiagram.tsx b/webui/src/components/TechniqueDiagram.tsx index 488efec..c1d0111 100644 --- a/webui/src/components/TechniqueDiagram.tsx +++ b/webui/src/components/TechniqueDiagram.tsx @@ -99,7 +99,7 @@ function mono( y: number, text: string, fill = FG, - size = 10, + size = 11.5, anchor: "start" | "middle" | "end" = "middle", ) { return ( @@ -122,7 +122,7 @@ function Glyph({ arch }: { arch: Arch }) { ))} - {mono(202, YC + 21, "Δt", T3, 9)} + {mono(202, YC + 21, "Δt", T3, 10.5)} ); } @@ -138,7 +138,7 @@ function Glyph({ arch }: { arch: Arch }) { ))} - {mono(px + 34, YC + 3, "dpt↑", T3, 9)} + {mono(px + 34, YC + 3, "dpt↑", T3, 10.5)} ); } @@ -153,7 +153,7 @@ function Glyph({ arch }: { arch: Arch }) { ))} - {mono(338, YC + 62, "dst .1 → .254", T3, 9)} + {mono(338, YC + 62, "dst .1 → .254", T3, 10.5)} ); } @@ -186,7 +186,7 @@ function Glyph({ arch }: { arch: Arch }) { {mono(321, YC + 4, "kf7x…q4z.sync.example.net", SIG, 10)} - {mono(480, YC + 20, "resolver", T3, 9)} + {mono(480, YC + 20, "resolver", T3, 10.5)} ); } @@ -202,7 +202,7 @@ function Glyph({ arch }: { arch: Arch }) { - {mono(320, YC + 56, "out ≫ in, sustained", T3, 9)} + {mono(320, YC + 56, "out ≫ in, sustained", T3, 10.5)} ); } @@ -214,8 +214,8 @@ function Glyph({ arch }: { arch: Arch }) { {xs.map((x, i) => i < 4 ? : , )} - {mono(436, YC + 22, "success", SIG, 9)} - {mono(256, YC + 22, "fail ×N", T3, 9)} + {mono(436, YC + 22, "success", SIG, 10.5)} + {mono(256, YC + 22, "fail ×N", T3, 10.5)} ); } @@ -233,7 +233,7 @@ function Glyph({ arch }: { arch: Arch }) { ))} - {mono(360, YC + 74, "act=deny burst", T3, 9)} + {mono(360, YC + 74, "act=deny burst", T3, 10.5)} ); } @@ -258,8 +258,8 @@ function Glyph({ arch }: { arch: Arch }) { d={pointsStar(452, 74, 6.5, 3)} fill={SIG} /> - {mono(452, 56, "new dst", SIG, 9)} - {mono(300, 172, "known baseline", T3, 9)} + {mono(452, 56, "new dst", SIG, 10.5)} + {mono(300, 172, "known baseline", T3, 10.5)} ); } @@ -271,7 +271,7 @@ function Glyph({ arch }: { arch: Arch }) { - {mono(344, base - 74, "×20 events/s", SIG, 9)} + {mono(344, base - 74, "×20 events/s", SIG, 10.5)} ); } @@ -281,7 +281,7 @@ function Glyph({ arch }: { arch: Arch }) { - {mono(332, 150, "Δt ≪ travel time", T3, 9)} + {mono(332, 150, "Δt ≪ travel time", T3, 10.5)} ); } @@ -305,7 +305,7 @@ function Pin({ x, y, label, accent }: { x: number; y: number; label: string; acc - {mono(x, y + 22, label, accent ? SIG : T3, 9)} + {mono(x, y + 22, label, accent ? SIG : T3, 10.5)} ); } @@ -327,13 +327,13 @@ export function TechniqueDiagram({ technique }: { technique: Technique }) { {`${technique.name}: ${CAPTION[arch]}`} {/* zone labels */} - + SOURCE - + {CAPTION[arch].toUpperCase()} - + DETECTION @@ -342,7 +342,7 @@ export function TechniqueDiagram({ technique }: { technique: Technique }) { {/* source chip */} - + {source[0]} {mono(74, YC + 12, source[1], T3, 9.5)} @@ -353,14 +353,14 @@ export function TechniqueDiagram({ technique }: { technique: Technique }) { {/* detection chip */} - {mono(569, YC - 4, technique.ndr_uc, FG, 10)} - {mono(565, YC + 12, technique.ndr_rule, T3, 8.5)} + {mono(569, YC - 4, technique.ndr_uc, FG, 11)} + {mono(565, YC + 12, technique.ndr_rule, T3, 10.5)} {/* varied fields = the emitted signal */} - + SIGNAL FIELDS - {mono(214, 206, technique.cef_fields_varied.join(" · ") || "—", SIG, 10, "start")} + {mono(238, 206, technique.cef_fields_varied.join(" · ") || "—", SIG, 11, "start")} ); } diff --git a/webui/src/index.css b/webui/src/index.css index 6eeaae7..8389783 100644 --- a/webui/src/index.css +++ b/webui/src/index.css @@ -114,9 +114,12 @@ background-size: 100% 100%, 34px 34px, 34px 34px; } -/* Uppercase micro-label used across the instrument UI. */ +/* Uppercase micro-label used across the instrument UI. + Was 10px, which was the single most common size on the rendered page. Uppercase + plus letter-spacing at that size is the hardest combination here to read, and + these are navigation labels an operator has to read to use the tool. */ .u-label { - font-size: 10px; + font-size: 12px; text-transform: uppercase; letter-spacing: 0.11em; font-weight: 600; @@ -140,7 +143,7 @@ itself rather than push the page sideways. */ .doc-prose { color: hsl(var(--foreground)); - font-size: 13.5px; + font-size: 15px; line-height: 1.75; } .doc-prose h1, @@ -204,7 +207,7 @@ .doc-prose pre code { background: none; padding: 0; - font-size: 11.5px; + font-size: 12.5px; line-height: 1.7; } .doc-prose blockquote { @@ -219,7 +222,7 @@ overflow-x: auto; border-collapse: collapse; margin: 1em 0; - font-size: 12px; + font-size: 12.5px; } .doc-prose th, .doc-prose td { diff --git a/webui/src/lib/catalogView.test.ts b/webui/src/lib/catalogView.test.ts index fc8ba0c..988d38c 100644 --- a/webui/src/lib/catalogView.test.ts +++ b/webui/src/lib/catalogView.test.ts @@ -185,3 +185,37 @@ describe("filterTechniques", () => { ]); }); }); + +// The type scale. +// +// Before it there were eleven hardcoded sizes across 84 call sites and no scale +// in the design doc at all. Measured on the rendered page, 58 of 104 text +// elements sat below 12px and only 3 reached the 16px browser default. The +// smallest thing on screen was an 8.5px rule id inside the signal-path diagram. +// +// This asserts the scale exists and stays ordered. It cannot catch a component +// picking the wrong rung, which is a judgement call, but it does catch the scale +// being quietly widened back out into eleven ad hoc values. +describe("type scale", () => { + it("is ordered, and nothing reads below 11px", async () => { + // Imported as raw text, not as a module. The config is plain JS with no + // type declaration, so a normal import fails `tsc` under noImplicitAny, and + // the frontend build, the installer job and the wheel job all run that + // build. `?raw` is typed as string by vite/client, so this stays type-clean. + const source = (await import("../../tailwind.config.js?raw")).default; + const px: Record = {}; + for (const [, name, value] of source.matchAll( + /(\w+):\s*\["([0-9.]+)px"/g, + )) { + px[name] = parseFloat(value); + } + + expect(px.micro).toBeGreaterThanOrEqual(11); + expect(px.label).toBeGreaterThan(px.micro); + expect(px.body).toBeGreaterThan(px.label); + expect(px.lede).toBeGreaterThan(px.body); + expect(px.title).toBeGreaterThan(px.lede); + // Prose has to clear the size the old UI used for everything. + expect(px.body).toBeGreaterThanOrEqual(14); + }); +}); diff --git a/webui/tailwind.config.js b/webui/tailwind.config.js index 9f3ea5c..032ef2d 100644 --- a/webui/tailwind.config.js +++ b/webui/tailwind.config.js @@ -18,6 +18,27 @@ export default { content: ["./index.html", "./src/**/*.{ts,tsx}"], theme: { extend: { + // The type scale. + // + // Before this there were ELEVEN hardcoded sizes across 84 call sites + // (9, 9.5, 10.5, 11, 11.5, 12, 12.5, 13, 14, 23, 25) and no scale in the + // design doc at all. That is not a scale, it is eleven separate decisions + // made one component at a time, each locally reasonable and never compared. + // Measured on the rendered page: 58 of 104 text elements sat below 12px and + // only 3 reached the 16px browser default. + // + // The distinction that matters is READ versus SCAN. Prose is read in + // sentences and needs size; tabular and monospace data is scanned, and + // density genuinely helps there. So `data` and `mono` stay tight while + // `body` and `lede` grow, and nothing is below 11px any more. + fontSize: { + micro: ["11px", { lineHeight: "1.45" }], // was 9 / 9.5, diagram captions + label: ["12px", { lineHeight: "1.5" }], // was 10 / 10.5, section + nav labels + data: ["12.5px", { lineHeight: "1.55" }], // scanned values, tags, chips + body: ["14px", { lineHeight: "1.6" }], // was 13, prose you actually read + lede: ["15px", { lineHeight: "1.6" }], // the technique objective + title: ["24px", { lineHeight: "1.25" }], + }, colors: { border: "hsl(var(--border))", input: "hsl(var(--input))",