From 89d313f9d0160b160e589ccef60ca9a3cc894422 Mon Sep 17 00:00:00 2001 From: Hashem Aldhaheri <158606+aenawi@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:56:45 +0400 Subject: [PATCH] Dock cards around the board, so a theme can arrange the instrument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page's furniture was in index.html. Adding a readout meant editing the markup, the stylesheet and app.js, which is three files and a fork for anybody who wants a different instrument on the same lab. The feed made the data shareable last week; this makes the layout data too. There are four docks now — top, left, right, bottom — and a card in one of them is a widget: a folder under ui/hud/widgets/ with a widget.json saying what it is, a widget.js, and a widget.css scoped to its own card. widgets.go walks the embedded UI at startup and serves the registry at /api/widgets, so adding a widget is a folder and a rebuild. The same shape as the theme store, and panels.go before it. What a widget is handed is the whole seam: a div, the feed, and the options its placement carried. No board object, no page elements, no controller. The board is twenty-five coupled methods over 1300 lines of three.js and publishing it as a plugin API would freeze hud/scene.js the day somebody wrote against it — a widget that wants to know what the lab is doing reads the feed, which is what the feed is for. Where a card goes is the theme's business. theme.json grows a layout, the server prunes it against the registry at startup — unknown widget, or a dock the widget does not fit — and each drop says why at the console, so the page only mounts placements that can be built. warroom docks three; chapter docks none, for the same reason its stylesheet overrides nothing, and a test fails if either starts to. Three widgets to have something real in the docks, all three reading the feed and nothing else. wire is the feed reading itself — open or not, how far the sequence has got, and what the server threw away because a reader could not keep up, because a recording made through a gap is worth knowing about before it is posted. tape is every frame written down, which is the closest thing on the page to what a replay will work from. tally counts verdicts by the rung that decided them, allowed and denied apart: "rung 3 allowed eleven and denied none" is a finding and "rung 3 answered eleven times" is not. The board is not a widget and does not pretend to be. app.js still creates it and drives it; the docks' part is reporting what they cover so the camera frames the diagram in the gap the cards leave. Making it a real widget under the contract above means the board reading the feed instead of being called, which is the next piece of work and not this one — a privileged contract that handed one widget the board object would be the plugin API this change exists to avoid. One trap, found by driving it. --pad-* is measured against the canvas and consumed against the window, and those differ by the height of the rung ladder, which sits below the viewport rather than over it. A card measured one way and positioned the other is short by exactly one ladder, which put the verdict 40px inside the bottom card. So the docks are measured twice, against both rectangles, and --rail-* is split out from --pad-*: the docks are positioned from the furniture alone, because a dock positioned from a number that includes its own size is a layout that never settles. Measured against the running lab on 2026-09-12, Chromium 1600x1000. chapter: no layer in the DOM, no cards, --rail-* and --pad-* equal. warroom: three cards in three docks, three stylesheets loaded, --pad-r 616 against --rail-r 346. A probe from lab-ubuntu reached rung 5 and the tally read 1/0; the attacker to :22 was denied at rung 4 and the tally read 0/1, which is a denial and not a tick. Folding the readouts rail moved the tape card 984..1244 -> 1276..1536 and the board re-framed. At 820px the cards stack at the foot of the page with no horizontal scroll. Verdict, view controls and the board's gauges clear every card: no overlapping rectangles. The only console error on the page is the favicon 404 it already had. make -C lab check passes: ten new Go tests, and the JavaScript parse check now finds the widget folders rather than carrying a list of them, since a list here would have been the third place to edit and the one nobody remembers. --- lab/README.md | 81 ++++- lab/checks/syntax.mjs | 36 ++- lab/control/main.go | 26 +- lab/control/themes.go | 46 +++ lab/control/ui/app.js | 87 ++++- lab/control/ui/hud/slots.js | 315 +++++++++++++++++++ lab/control/ui/hud/themes/warroom/theme.json | 9 +- lab/control/ui/hud/widgets/tally/widget.css | 73 +++++ lab/control/ui/hud/widgets/tally/widget.js | 124 ++++++++ lab/control/ui/hud/widgets/tally/widget.json | 7 + lab/control/ui/hud/widgets/tape/widget.css | 47 +++ lab/control/ui/hud/widgets/tape/widget.js | 145 +++++++++ lab/control/ui/hud/widgets/tape/widget.json | 7 + lab/control/ui/hud/widgets/wire/widget.css | 46 +++ lab/control/ui/hud/widgets/wire/widget.js | 93 ++++++ lab/control/ui/hud/widgets/wire/widget.json | 7 + lab/control/ui/index.html | 3 +- lab/control/ui/style.css | 108 +++++++ lab/control/widgets.go | 203 ++++++++++++ lab/control/widgets_test.go | 285 +++++++++++++++++ 20 files changed, 1735 insertions(+), 13 deletions(-) create mode 100644 lab/control/ui/hud/slots.js create mode 100644 lab/control/ui/hud/widgets/tally/widget.css create mode 100644 lab/control/ui/hud/widgets/tally/widget.js create mode 100644 lab/control/ui/hud/widgets/tally/widget.json create mode 100644 lab/control/ui/hud/widgets/tape/widget.css create mode 100644 lab/control/ui/hud/widgets/tape/widget.js create mode 100644 lab/control/ui/hud/widgets/tape/widget.json create mode 100644 lab/control/ui/hud/widgets/wire/widget.css create mode 100644 lab/control/ui/hud/widgets/wire/widget.js create mode 100644 lab/control/ui/hud/widgets/wire/widget.json create mode 100644 lab/control/widgets.go create mode 100644 lab/control/widgets_test.go diff --git a/lab/README.md b/lab/README.md index 37fed25..3792143 100644 --- a/lab/README.md +++ b/lab/README.md @@ -1286,9 +1286,14 @@ lab/ ├── reading.js what a Result says — pure, and the only tested part ├── setpieces.js one exported function per attack id ├── themes.js the theme store: what exists, and which is selected - └── themes/ one folder per palette — see below - ├── chapter/ the guide's own colours; overrides nothing - └── warroom/ amber on near-black + ├── slots.js the four docks, and what a widget is handed + ├── themes/ one folder per palette — see below + │ ├── chapter/ the guide's own colours; overrides nothing + │ └── warroom/ amber on near-black, and it docks three cards + └── widgets/ one folder per card — see below + ├── wire/ what the feed itself is doing + ├── tape/ every frame on the feed, written down + └── tally/ which rung has been deciding things ``` ### The feed @@ -1424,6 +1429,76 @@ same choice `missing()` makes for set-pieces, for the same reason: a broken extra must not take the page with it, and a silent one is worse than a loud one. +### Widgets and slots + +A theme can also dock cards. There are four docks around the board — `top`, +`left`, `right`, `bottom` — and a card in one of them is a widget. + +One widget is one folder under `control/ui/hud/widgets/`: + +``` +widgets/tape/ +├── widget.json id, name, note, slots it fits in, author +├── widget.js one exported create(), and what it returns +└── widget.css its own styling, scoped to its own card +``` + +`widgets.go` walks the embedded UI at startup and serves the registry at +`/api/widgets`. Adding a widget is a folder and a rebuild, and nothing else is +edited — the same property the theme store and `panels.go` already have. + +What a widget is handed is the whole seam, and it is deliberately small: + +```js +export function create({ el, feed, options }) { + const off = feed.on("verdict", (data) => { /* draw into el */ }); + return { destroy: off }; +} +``` + +`el` is the card's body and is yours until `destroy()`. `feed` is +`window.LabFeed` — `on()` for the always-on sources, `want()` for a capture or +a log tail, both handing back the function that undoes them. `options` is +whatever the placement carried, verbatim. + +And that is all of it. There is no board object, no page element and no +controller, because the board is twenty-five coupled methods over 1300 lines of +three.js and publishing it as a plugin API would freeze `hud/scene.js` the day +somebody wrote against it. A widget that wants to know what the lab is doing +reads the feed, which is what the feed is for. + +Where a card goes is the theme's business, not the widget's, and it is in +`theme.json`: + +```json +"layout": [ + { "widget": "wire", "slot": "top" }, + { "widget": "tape", "slot": "right", "options": { "limit": 60 } }, + { "widget": "tally", "slot": "bottom" } +] +``` + +That is what `warroom` ships. `chapter` docks nothing — for the same reason its +stylesheet overrides nothing: it is the page as it was before any of this, and +it is where a saved theme id falls back to. A test fails if either starts to. + +A widget declares which docks it is willing to sit in, because the widget is +what knows: a tape of every frame on the wire wants a tall column and reads as +nonsense in a 90px strip. The server prunes the layout against the registry at +startup — an unknown widget, or one placed in a dock it does not fit — so the +page only ever mounts placements that can be built. Each dropped one says why at +the console. + +The board itself is not a widget. It is the floor of the window: `app.js` +creates it and drives it, and the docks' part in that is telling it how much of +itself they are covering, so the camera frames the diagram in the gap the cards +leave. Folding a rail moves the cards and re-frames the board with them. + +Each widget's stylesheet is scoped to `.card[data-widget=""]`, and a test +fails if a selector reaches wider — the same test, and the same reason, as the +one holding a theme to the nine `--board-*` properties. Below 1000px the cards +stop floating and stack at the foot of the page, which is what the rails do. + ### If you edit the UI, rebuild `main.go` does `//go:embed ui`, so the page you get on :8099 is the copy diff --git a/lab/checks/syntax.mjs b/lab/checks/syntax.mjs index 42123c0..305ac44 100644 --- a/lab/checks/syntax.mjs +++ b/lab/checks/syntax.mjs @@ -29,7 +29,7 @@ ============================================================ */ import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -50,12 +50,44 @@ const FILES = [ ["lab/control/ui/hud/setpieces.js", "module"], ["lab/control/ui/hud/reading.js", "module"], ["lab/control/ui/hud/themes.js", "module"], + ["lab/control/ui/hud/slots.js", "module"], ["assets/app.js", "commonjs"], ["assets/anim.js", "commonjs"], ["assets/nav.js", "commonjs"], - ["assets/sandbox.js", "commonjs"] + ["assets/sandbox.js", "commonjs"], + ...widgets() ]; +/* The widgets are found rather than listed, because that is the whole + property widgets.go was written for: adding one is a folder and a rebuild, + not an edit to a list in three places. A list here would be the third + place, and the one nobody remembers — a widget added without its line in it + would ship unchecked, and a widget failing to import is a card that says + "did not load" with the reason only in the browser's console. + + A folder with no widget.js is not this check's problem: the server skips it + with a line saying so, and TestEmbeddedWidgetsAreWellFormed fails. */ +function widgets() { + const dir = "lab/control/ui/hud/widgets"; + let entries; + try { + entries = readdirSync(join(root, dir), { withFileTypes: true }); + } catch { + return []; /* no widgets embedded, which is a valid build */ + } + return entries + .filter((e) => e.isDirectory()) + .map((e) => [dir + "/" + e.name + "/widget.js", "module"]) + .filter(([rel]) => { + try { + readFileSync(join(root, rel)); + return true; + } catch { + return false; + } + }); +} + let failed = 0; for (const [rel, goal] of FILES) { diff --git a/lab/control/main.go b/lab/control/main.go index 6414953..f90459e 100644 --- a/lab/control/main.go +++ b/lab/control/main.go @@ -506,10 +506,15 @@ func routes(c *Controller) http.Handler { } mux.Handle("/", assets) - // Walked once, at startup: the themes are in the binary and cannot appear - // while it runs. Anything skipped has already said why at the console by - // the time this returns. - themes := loadThemes(ui) + // Walked once, at startup: the themes and the widgets are in the binary + // and cannot appear while it runs. Anything skipped has already said why + // at the console by the time this returns. + // + // The registry is read first because the layouts are pruned against it: a + // theme that docks a widget nobody embedded is served with that card + // missing rather than with a placement the page cannot honour. + widgets := loadWidgets(ui) + themes := resolvePlacements(loadThemes(ui), widgets) writeJSON := func(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") @@ -563,6 +568,19 @@ func routes(c *Controller) http.Handler { writeJSON(w, themes) }) + // The widget registry: which cards were embedded under ui/hud/widgets, + // and which docks each one is willing to sit in. The page does not need + // this to mount a layout — a theme's layout already names what to build — + // but it is how a reader writing a theme finds out what there is to place, + // and it is what tells the page that an id it was handed is real. + mux.HandleFunc("/api/widgets", func(w http.ResponseWriter, r *http.Request) { + if widgets == nil { + writeJSON(w, []Widget{}) + return + } + writeJSON(w, widgets) + }) + mux.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) { c.Observe(r.Context()) writeJSON(w, c.Snapshot()) diff --git a/lab/control/themes.go b/lab/control/themes.go index ab1c897..f8dc908 100644 --- a/lab/control/themes.go +++ b/lab/control/themes.go @@ -49,6 +49,31 @@ type Theme struct { // button still wins afterwards. Ground string `json:"ground"` Author string `json:"author"` + // Layout is the cards this theme docks around the board, in the order it + // wants them stacked. Empty for a theme that only repaints, which is what + // every theme was before widgets.go existed — and what the base theme + // still is. + // + // A placement here is a request. Whether it can be honoured depends on + // what is in the widget registry, which this file does not know about: + // resolvePlacements() prunes the list at startup, so by the time the page + // is handed a layout every entry in it is buildable. + Layout []Placement `json:"layout"` +} + +// Placement is one card: which widget, in which dock, with what on it. +type Placement struct { + Widget string `json:"widget"` + Slot string `json:"slot"` + // Title is what the card's header says. Optional — the widget's own name + // is the fallback, and it is the better label for a card placed once. A + // theme docking two of the same widget is the case this exists for. + Title string `json:"title,omitempty"` + // Options is handed to the widget verbatim and means nothing here. Every + // widget documents its own, and a widget that is given a key it does not + // recognise ignores it — so a theme written against a newer build of a + // widget degrades rather than breaks. + Options map[string]any `json:"options,omitempty"` } // themesDir is where the store lives inside the UI filesystem. It is a @@ -130,5 +155,26 @@ func readTheme(fsys fs.FS, dir string) (Theme, bool) { log.Printf("hud theme %q: ground %q is not light, dark or any — read as any", dir, t.Ground) t.Ground = "any" } + + // The structural half of the layout: a placement with no widget named, or + // one aimed at a dock this build does not have, cannot become a card + // however the registry turns out. Dropping it here rather than at the + // page keeps the reason next to the folder that got it wrong. + // + // Whether the widget exists is resolvePlacements()' question. + var layout []Placement + for _, p := range t.Layout { + if p.Widget == "" { + log.Printf("hud theme %q: a placement names no widget, dropped", dir) + continue + } + if !validSlots[p.Slot] { + log.Printf("hud theme %q: %q asks for slot %q, which is not a dock, dropped", dir, p.Widget, p.Slot) + continue + } + layout = append(layout, p) + } + t.Layout = layout + return t, true } diff --git a/lab/control/ui/app.js b/lab/control/ui/app.js index 4189469..74178d8 100644 --- a/lab/control/ui/app.js +++ b/lab/control/ui/app.js @@ -55,6 +55,7 @@ if (!api || !sel) return; api.init(board).then(function (applied) { + mountLayout(applied); var list = api.list(); if (!list.length) return; /* keep the one option markup shipped */ sel.innerHTML = list.map(function (t) { @@ -66,12 +67,47 @@ sel.addEventListener("change", function () { api.apply(sel.value, board).then(function (applied) { + mountLayout(applied); sel.value = applied; sel.title = (api.get(applied) || {}).note || ""; }); }); } + /* ---- the cards docked around the board ------------------------- + A theme is two things now: nine colours, and a layout. The colours are + hud/themes.js' business and the layout is hud/slots.js', and this is + where the page puts them together — selecting a theme repaints the board + and re-docks the cards, in that order, because the cards are styled off + the palette they land in. + + Which widget goes where is not decided here and is not in index.html. + It is in the theme's theme.json, pruned by the server against what was + actually embedded, so this function never has to decide what to do about + a card it cannot build. + + Everything here is optional twice over: no slots module means no cards, + and a theme with no layout means no cards either. Both leave the page as + the board and two rails, which is what it was. */ + function mountLayout(id) { + var api = window.LabHUD && window.LabHUD.themes; + var slots = window.LabHUD && window.LabHUD.slots; + if (!api || !slots) return; + + if (!slotsStarted) { + /* The feed is not passed: slots.js reads window.LabFeed, and feed.js + is a classic script that ran before this one. Handing it `feed` from + here would hand it undefined — this runs from startBoard(), which is + called above the line that reads the feed off window. */ + slots.init({ onLayout: measureRails }); + slotsStarted = true; + } + + var theme = api.get(id); + slots.apply(theme && theme.layout); + } + var slotsStarted = false; + /* ---- the board -------------------------------------------------- lab/control/ui/hud is an ES module, because three.js is one. It hands itself over on window.LabHUD and fires an event when it has loaded. @@ -318,11 +354,60 @@ } root.setProperty("--ladder-h", Math.round(ladderH) + "px"); + + /* Two sets of tokens, because there are now two answers and the + difference between them is the docked cards. + + --rail-* is the furniture that is bolted on: the rails, the bar, the + ladder. The slot layer is positioned from these, and from these only. + --pad-* is that plus whatever the cards are covering, and it is what + every floating thing over the board already reads — so the gauges, the + verdict and the view controls move out of a card's way with nothing in + the stylesheet edited to teach them about cards. + + The two cannot be one token. The docks are laid out from --rail-*, so + their own size going back into the token that positions them would be + a layout that moves every time it is measured. Keeping the feedback out + is the whole reason for the split; the camera, which cannot resize a + card, is free to read the sum. */ + root.setProperty("--rail-l", Math.round(covered.left) + "px"); + root.setProperty("--rail-r", Math.round(covered.right) + "px"); + root.setProperty("--rail-t", Math.round(covered.top) + "px"); + root.setProperty("--rail-b", Math.round(Math.max(covered.bottom, ladderH)) + "px"); + + /* Each dock sits inboard of the rail on its side, so the overlap with the + board is the larger of the two rather than their sum. + + Measured twice, against two rectangles, and the bottom edge is why. + The camera has to know what covers the *canvas*; a floating panel has + to know what is occupied at the bottom of the *window*, and those + differ by the height of the rung ladder, which sits below the viewport + rather than over it. This is the distinction --pad-b has always made + for the ladder itself — `max(covered.bottom, ladderH)` — and a docked + card runs into it the same way: a card measured against the viewport + and then positioned against the window is short by exactly one ladder, + which put the verdict 40px inside the bottom card. */ + var slots = window.LabHUD && window.LabHUD.slots; + var padBottom = Math.max(covered.bottom, ladderH); + + if (slots && !floatQ.matches) { + var canvasBox = document.querySelector(".viewport").getBoundingClientRect(); + var windowBox = document.querySelector(".app").getBoundingClientRect(); + var onCanvas = slots.covered(canvasBox); + var onWindow = slots.covered(windowBox); + + covered.left = Math.max(covered.left, onCanvas.left); + covered.right = Math.max(covered.right, onCanvas.right); + covered.top = Math.max(covered.top, onCanvas.top); + covered.bottom = Math.max(covered.bottom, onCanvas.bottom); + padBottom = Math.max(padBottom, onWindow.bottom); + } + root.setProperty("--pad-l", Math.round(covered.left) + "px"); root.setProperty("--pad-r", Math.round(covered.right) + "px"); root.setProperty("--pad-t", Math.round(covered.top) + "px"); /* The floating panels clear the ladder; the camera, above, does not. */ - root.setProperty("--pad-b", Math.round(Math.max(covered.bottom, ladderH)) + "px"); + root.setProperty("--pad-b", Math.round(padBottom) + "px"); if (board && board.setViewInset) board.setViewInset(covered); } diff --git a/lab/control/ui/hud/slots.js b/lab/control/ui/hud/slots.js new file mode 100644 index 0000000..fcaa8b1 --- /dev/null +++ b/lab/control/ui/hud/slots.js @@ -0,0 +1,315 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* ============================================================ + Widgets and slots. + + The page has four docks around the board — top, left, right, bottom — and + a dock holds cards. This file builds them, and it is the only thing that + knows how a card becomes a widget. + + What a widget gets is the whole seam, and it is deliberately tiny: + + export function create({ el, feed, options }) { + ... + return { destroy() {} }; // optional + } + + el a div. The card's body, already in the document and sized. + Yours until destroy() — nothing else writes into it. + feed window.LabFeed. on(type, fn) for the always-on sources, + want(sources) for a capture or a log tail. Both hand back a + function that undoes them, and a widget that calls neither in + destroy() is a widget that leaks. + options whatever the theme's placement carried, verbatim. Yours to + read and yours to ignore. + + And that is all. No board object, no page elements, no controller. The + board is twenty-five coupled methods over 1300 lines of three.js, and + publishing it as a plugin API would freeze hud/scene.js the day somebody + wrote against it. A widget that wants to know what the lab is doing reads + the feed, which is what the feed is for. + + The board is not a card and does not live here. It is the floor of the + window — app.js creates it, drives it, and tells it how much of itself the + furniture is covering. This file's part in that is covered(): the docks + report what they hide, app.js adds it to what the rails hide, and the + camera aims at the gap that is left. It reports to the camera and not to + the stylesheet on purpose: the docks are positioned from --pad-*, so + feeding their size back into --pad-* would be a layout that never settles. + + Nothing here throws at a caller and nothing here is required. A widget + that will not import leaves a card saying so and the other cards mount; a + layout that is empty builds no layer at all, which is the page exactly as + it was before this file existed. + ============================================================ */ + +/* The docks, in the order they are built. Matches validSlots in widgets.go — + the server drops a placement aimed at anything else, so a name that + reaches this file is one of these. */ +export const SLOTS = ["top", "left", "right", "bottom"]; + +let layer = null; /* the .slots element, or null when nothing is docked */ +let docks = {}; /* slot -> the dock element */ +let live = []; /* [{ placement, card, instance }] */ +let generation = 0; /* which apply() is the current one */ +let observer = null; /* watches the docks for a card changing size */ + +let feed = null; +let onLayout = function () {}; + +/* ---------- starting up ---------------------------------------- */ + +/* Told once, by the page. `feed` is handed on rather than read off window + here so a test — or a second board in one document — can pass its own. + + onLayout is called after the cards of an apply() have mounted, because + that is when covered() starts answering differently and the camera needs + to be told. */ +export function init(options) { + const o = options || {}; + feed = o.feed || (typeof window !== "undefined" ? window.LabFeed : null) || null; + if (typeof o.onLayout === "function") onLayout = o.onLayout; +} + +/* ---------- what is docked ------------------------------------- */ + +/* The placements that actually built something, in mount order. For a test, + and for anybody asking the page what it is showing. */ +export function list() { + return live.map((w) => ({ widget: w.placement.widget, slot: w.placement.slot })); +} + +/* How much of `within` each dock hides, in CSS pixels from that edge. + * + * `within` is the rectangle being covered — the board's viewport, when app.js + * asks. Overlap with it rather than the dock's own width, and measured from + * the live elements rather than added up from the cards, because a dock that + * wrapped or a card shorter than its content covers what it covers and not + * what it asked for. It is the same sum measureRails() does for the rails, + * against the same rectangle, so the two answers can simply be compared. + * + * An empty dock is zero. The docks have no background of their own, so a dock + * with nothing in it is not in the way of anything. */ +export function covered(within) { + const out = { left: 0, right: 0, top: 0, bottom: 0 }; + if (!layer) return out; + const box = within || layer.getBoundingClientRect(); + + for (const slot of SLOTS) { + const dock = docks[slot]; + if (!dock || !dock.firstChild) continue; + const r = dock.getBoundingClientRect(); + if (slot === "left") out.left = Math.max(0, Math.round(r.right - box.left)); + if (slot === "right") out.right = Math.max(0, Math.round(box.right - r.left)); + if (slot === "top") out.top = Math.max(0, Math.round(r.bottom - box.top)); + if (slot === "bottom") out.bottom = Math.max(0, Math.round(box.bottom - r.top)); + } + return out; +} + +/* ---------- building it ---------------------------------------- */ + +/* Unmount everything and build `layout` instead. + * + * This is what a theme switch calls, and a theme switch can happen twice + * before the first import has resolved — a reader holding the arrow keys in + * the picker does exactly that. So each call takes a generation, and a card + * whose generation is no longer the current one is thrown away rather than + * appended. Without it the docks end up holding the union of every theme + * that was passed through. + * + * Returns the number of cards that mounted, which is not always the number + * asked for: a widget that fails to import is reported on its card and + * counted as a miss. */ +export async function apply(layout) { + const mine = ++generation; + clear(); + + const wanted = Array.isArray(layout) ? layout.filter(usable) : []; + if (!wanted.length) { + onLayout(); + return 0; + } + + build(); + + /* Every module at once rather than one after another: they are four small + files off the same origin, and awaiting them in series makes the last + card land four round trips late for no reason. */ + const loaded = await Promise.all(wanted.map(load)); + if (mine !== generation) return 0; /* a newer apply() owns the layer */ + + let mounted = 0; + for (let i = 0; i < wanted.length; i++) { + if (mount(wanted[i], loaded[i])) mounted++; + } + + onLayout(); + return mounted; +} + +/* Take the layer down. Every widget is told before its DOM goes, because a + widget that registered a feed listener has to be given the chance to drop + it — and a listener left behind is a listener writing into a div that is + no longer in the document. */ +export function clear() { + for (const w of live) { + if (!w.instance || typeof w.instance.destroy !== "function") continue; + try { + w.instance.destroy(); + } catch (e) { + console.error("widget " + w.placement.widget + " threw on destroy: " + e); + } + } + live = []; + + if (observer) { + observer.disconnect(); + observer = null; + } + if (layer && layer.parentNode) layer.parentNode.removeChild(layer); + layer = null; + docks = {}; +} + +/* A placement the server has already pruned should be well-formed, so this is + a guard against a hand-edited response rather than a validation pass. */ +function usable(p) { + return !!(p && typeof p.widget === "string" && p.widget && SLOTS.indexOf(p.slot) >= 0); +} + +/* The layer and its four docks. Built on demand and removed when empty, so a + theme that docks nothing leaves no element over the board at all — an + invisible div that swallows a drag is the worst version of this file. */ +function build() { + layer = document.createElement("div"); + layer.className = "slots"; + layer.id = "slots"; + + for (const slot of SLOTS) { + const dock = document.createElement("div"); + dock.className = "dock dock-" + slot; + dock.dataset.slot = slot; + docks[slot] = dock; + layer.appendChild(dock); + } + + const app = document.querySelector(".app") || document.body; + app.appendChild(layer); + + /* A card changes size after it mounts — a tape fills up, a verdict line + wraps — and every one of those changes what the docks are covering. The + camera is aimed at the gap the docks leave, so it has to be told, and + nothing else on the page would notice. + + Safe against a loop by construction: onLayout re-measures and moves the + camera, and the camera cannot resize a card. */ + if (typeof ResizeObserver === "function") { + observer = new ResizeObserver(function () { onLayout(); }); + for (const slot of SLOTS) observer.observe(docks[slot]); + } +} + +/* Which widgets have had their stylesheet put in the document, by id. */ +const sheets = new Set(); + +/* Put a widget's stylesheet in the document. One way, like the theme store's: + every rule in it is scoped to .card[data-widget=""], so a sheet that + has been loaded and then undocked is inert rather than something this file + has to decide when it is safe to remove. + + widgets.go requires the file, so a widget that was served has one. A sheet + that fails to load anyway resolves too — an unstyled card is a readable + card, and a card that never appears because a stylesheet 404'd is not. */ +function ensureSheet(id) { + if (sheets.has(id)) return Promise.resolve(); + sheets.add(id); + return new Promise((resolve) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "hud/widgets/" + encodeURIComponent(id) + "/widget.css"; + link.dataset.hudWidget = id; + link.addEventListener("load", resolve); + link.addEventListener("error", () => { + console.warn("widget " + id + ": widget.css did not load, the card is unstyled"); + resolve(); + }); + document.head.appendChild(link); + }); +} + +/* Import one widget's module, and its stylesheet with it. + * + * The sheet is waited for rather than fired off: a widget that measures its + * own body — and a tape that wants to know how many lines fit is the obvious + * one — would otherwise measure it at whatever size an unstyled div happens + * to be. + * + * A failure resolves to null rather than rejecting: one missing file must not + * take the rest of the layout with it, and the card it was going to fill is + * where the complaint goes. */ +function load(placement) { + const id = placement.widget; + const module = import("./widgets/" + encodeURIComponent(id) + "/widget.js") + .catch((e) => { + console.warn("widget " + id + ": did not load (" + e.message + ")"); + return null; + }); + return Promise.all([module, ensureSheet(id)]).then((both) => both[0]); +} + +/* Build one card and hand its body to the widget. + * + * A widget that throws in create() has its card left saying so. That is a + * deliberate change of tone from the rest of the page, which fails quietly: + * a broken set-piece looks like an action that never had one, and that cost + * this project a release. A card is a box with a name on it, so an empty one + * is already a visible question — answering it in the box is cheaper than + * asking somebody to open the console. */ +function mount(placement, module) { + const name = placement.widget; + const card = document.createElement("section"); + card.className = "card glass"; + card.dataset.widget = name; + + const head = document.createElement("div"); + head.className = "card-head"; + const title = document.createElement("span"); + title.className = "card-t"; + title.textContent = placement.title || name; + head.appendChild(title); + + const body = document.createElement("div"); + body.className = "card-body"; + + card.appendChild(head); + card.appendChild(body); + docks[placement.slot].appendChild(card); + + if (!module || typeof module.create !== "function") { + fail(body, module ? "no create()" : "did not load"); + return false; + } + + let instance = null; + try { + instance = module.create({ + el: body, + feed: feed, + options: placement.options || {} + }); + } catch (e) { + console.error("widget " + name + " threw on create: " + e); + fail(body, "threw while starting"); + return false; + } + + live.push({ placement: placement, card: card, instance: instance || null }); + return true; +} + +function fail(body, why) { + body.classList.add("card-dead"); + body.textContent = why; +} diff --git a/lab/control/ui/hud/themes/warroom/theme.json b/lab/control/ui/hud/themes/warroom/theme.json index c6d3572..d55c188 100644 --- a/lab/control/ui/hud/themes/warroom/theme.json +++ b/lab/control/ui/hud/themes/warroom/theme.json @@ -1,7 +1,12 @@ { "id": "warroom", "name": "Warroom", - "note": "An operations wall: amber on near-black, with the verdict colours pushed apart so a denial reads across a room.", + "note": "An operations wall: amber on near-black, with the verdict colours pushed apart so a denial reads across a room. It docks three cards — the wire along the top, the tape down the right, and the rung tally across the bottom. Press [ or ] to fold a rail if you want the board back.", "ground": "dark", - "author": "the guide" + "author": "the guide", + "layout": [ + { "widget": "wire", "slot": "top" }, + { "widget": "tape", "slot": "right", "options": { "limit": 60 } }, + { "widget": "tally", "slot": "bottom" } + ] } diff --git a/lab/control/ui/hud/widgets/tally/widget.css b/lab/control/ui/hud/widgets/tally/widget.css new file mode 100644 index 0000000..6d73657 --- /dev/null +++ b/lab/control/ui/hud/widgets/tally/widget.css @@ -0,0 +1,73 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* Scoped to this widget's card, and a Go test says so. */ + +.card[data-widget="tally"] .tally-rows { + display: grid; + gap: 3px; +} + +.card[data-widget="tally"] .tally-row { + display: grid; + grid-template-columns: 6ch 1fr 7ch; + gap: 8px; + align-items: center; +} + +.card[data-widget="tally"] .tally-n { + font-family: var(--mono); + font-size: 10px; + color: var(--text-muted); +} + +.card[data-widget="tally"] .tally-bar { + display: flex; + height: 8px; + border-radius: 4px; + background: var(--bg-sunken); + overflow: hidden; +} + +.card[data-widget="tally"] .tally-ok { background: var(--ok); width: 0; } +.card[data-widget="tally"] .tally-no { background: var(--danger); width: 0; } + +.card[data-widget="tally"] .tally-v { + font-family: var(--mono); + font-size: 11px; + text-align: right; + color: var(--text); +} + +.card[data-widget="tally"] .tally-foot { + margin-top: 6px; + font-size: 11px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* A horizontal dock gets the rungs side by side: five rows of an 8px bar + stacked in a 40px strip is five bars nobody can tell apart. */ +.dock-top .card[data-widget="tally"] .tally-rows, +.dock-bottom .card[data-widget="tally"] .tally-rows { + grid-auto-flow: column; + grid-auto-columns: minmax(90px, 1fr); + gap: 0 12px; +} + +.dock-top .card[data-widget="tally"] .tally-row, +.dock-bottom .card[data-widget="tally"] .tally-row { + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + align-items: baseline; +} + +.dock-top .card[data-widget="tally"] .tally-bar, +.dock-bottom .card[data-widget="tally"] .tally-bar { + grid-column: 1 / -1; +} + +.dock-top .card[data-widget="tally"] .tally-foot, +.dock-bottom .card[data-widget="tally"] .tally-foot { display: none; } diff --git a/lab/control/ui/hud/widgets/tally/widget.js b/lab/control/ui/hud/widgets/tally/widget.js new file mode 100644 index 0000000..2f059a9 --- /dev/null +++ b/lab/control/ui/hud/widgets/tally/widget.js @@ -0,0 +1,124 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* ============================================================ + Which rung decided it. + + The ladder under the board marks the rung that decided the last + connection. This counts all of them, which answers a different question: a + session where every denial came from rung 4 has tested the firewall a lot + and the tailnet's access rules not at all, and that is invisible one + verdict at a time. + + Allowed and denied are counted apart on purpose. "Rung 3 answered eleven + times" is not a finding; "rung 3 allowed eleven and denied none" is, and + so is the reverse. A bar that merged them would be the kind of drawing + this project exists to argue against — a count with the outcome averaged + out of it. + + Nothing here is drawn that was not measured. A rung with no verdicts + against it shows a zero and no bar, rather than an empty bar that reads as + a measurement of nothing happening. + + Options: none. + ============================================================ */ + +const RUNGS = [ + [1, "is anything alive"], + [2, "is there a path"], + [3, "did the tailnet allow it"], + [4, "did the firewall allow it"], + [5, "was anything listening"] +]; + +export function create(host) { + const el = host.el; + const feed = host.feed; + + const counts = {}; + for (const [n] of RUNGS) counts[n] = { ok: 0, no: 0 }; + + const rows = {}; + const table = document.createElement("div"); + table.className = "tally-rows"; + + for (const [n, what] of RUNGS) { + const row = document.createElement("div"); + row.className = "tally-row"; + + const name = document.createElement("div"); + name.className = "tally-n"; + name.textContent = "RUNG " + n; + name.title = what; + + const bar = document.createElement("div"); + bar.className = "tally-bar"; + const ok = document.createElement("span"); + ok.className = "tally-ok"; + const no = document.createElement("span"); + no.className = "tally-no"; + bar.appendChild(ok); + bar.appendChild(no); + + const num = document.createElement("div"); + num.className = "tally-v"; + num.textContent = "0"; + + row.appendChild(name); + row.appendChild(bar); + row.appendChild(num); + table.appendChild(row); + + rows[n] = { ok: ok, no: no, num: num }; + } + + const foot = document.createElement("div"); + foot.className = "tally-foot"; + foot.textContent = "nothing has been run yet"; + + el.appendChild(table); + el.appendChild(foot); + + if (!feed) { + foot.textContent = "no feed on this page"; + return {}; + } + + function record(data) { + const res = data && data.result; + if (!res || !counts[res.rung]) return; + + counts[res.rung][res.ok ? "ok" : "no"]++; + draw(); + + foot.textContent = (data.id || data.kind || "something") + " — " + + (res.ok ? "allowed" : "denied") + " at rung " + res.rung; + } + + function draw() { + /* Every bar is a share of the busiest rung, not of the total. Shares of + the total make four rungs invisible the moment one of them is being + exercised, which is exactly when the other four matter. */ + let most = 0; + for (const [n] of RUNGS) most = Math.max(most, counts[n].ok + counts[n].no); + + for (const [n] of RUNGS) { + const c = counts[n]; + const total = c.ok + c.no; + rows[n].ok.style.width = most ? (100 * c.ok / most) + "%" : "0"; + rows[n].no.style.width = most ? (100 * c.no / most) + "%" : "0"; + rows[n].num.textContent = total ? c.ok + " / " + c.no : "0"; + rows[n].num.title = total ? c.ok + " allowed, " + c.no + " denied" : "nothing reached this rung"; + } + } + + const offVerdict = feed.on("verdict", record); + const offFlow = feed.on("flow", record); + + return { + destroy: function () { + offVerdict(); + offFlow(); + } + }; +} diff --git a/lab/control/ui/hud/widgets/tally/widget.json b/lab/control/ui/hud/widgets/tally/widget.json new file mode 100644 index 0000000..a0a24d2 --- /dev/null +++ b/lab/control/ui/hud/widgets/tally/widget.json @@ -0,0 +1,7 @@ +{ + "id": "tally", + "name": "Which rung decided it", + "note": "Every verdict and every probe since the page loaded, counted by the rung that decided it and split by whether it was allowed. A defence that never answers is a defence you have not tested.", + "slots": ["top", "left", "right", "bottom"], + "author": "the guide" +} diff --git a/lab/control/ui/hud/widgets/tape/widget.css b/lab/control/ui/hud/widgets/tape/widget.css new file mode 100644 index 0000000..55cbd58 --- /dev/null +++ b/lab/control/ui/hud/widgets/tape/widget.css @@ -0,0 +1,47 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* Scoped to this widget's card, and a Go test says so. */ + +.card[data-widget="tape"] .tape-lines { + font-family: var(--mono); + font-size: 11px; + line-height: 1.45; +} + +.card[data-widget="tape"] .tape-line { + display: grid; + grid-template-columns: 4ch 7ch 1fr; + gap: 8px; + padding: 1px 0; + border-top: 1px solid var(--border); + /* A frame is one line. A packet line is 120 characters and wrapping it + turns the tape into paragraphs, which loses the thing a tape is for: + one row, one event, countable at a glance. */ + white-space: nowrap; + overflow: hidden; +} + +.card[data-widget="tape"] .tape-line:first-child { border-top: 0; } + +.card[data-widget="tape"] .tape-waiting { + font-family: var(--sans); + font-size: 11.5px; + color: var(--text-faint); +} + +.card[data-widget="tape"] .tape-seq { color: var(--text-faint); } +.card[data-widget="tape"] .tape-type { color: var(--text-muted); } +.card[data-widget="tape"] .tape-said { + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; +} + +/* The four types that are the lab answering, rather than the lab being + polled. They are what a reader is waiting for, so they are the ones with a + colour on them. */ +.card[data-widget="tape"] .tape-line[data-type="verdict"] .tape-type, +.card[data-widget="tape"] .tape-line[data-type="flow"] .tape-type { color: var(--accent); } +.card[data-widget="tape"] .tape-line[data-type="packet"] .tape-type { color: var(--danger); } +.card[data-widget="tape"] .tape-line[data-type="note"] .tape-type { color: var(--warn); } diff --git a/lab/control/ui/hud/widgets/tape/widget.js b/lab/control/ui/hud/widgets/tape/widget.js new file mode 100644 index 0000000..c25b24c --- /dev/null +++ b/lab/control/ui/hud/widgets/tape/widget.js @@ -0,0 +1,145 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* ============================================================ + The tape. + + One line per frame, newest at the top, with the sequence number the server + gave it. It is the wire written down — which makes it the closest thing on + the page to what a recording will hold, and the cheapest way to find out + whether an event somebody wants to replay is actually on the feed. + + The three polled sources are hidden by default, and that is a decision + taken from watching it run rather than from reading the type list. `stat`, + `status` and `netcheck` are re-read every three seconds whether or not + anything changed, and `tailscale netcheck` is a screenful; with them in, + one probe put nineteen lines on the tape and two of them were the probe. + The default is the frames that mean the lab did something — a verdict, a + flow, a packet, a log line, a configuration change — and `hide: []` puts + the polls back for anyone watching the polling itself. + + Newest at the top rather than a tail that scrolls: a tape the reader has + to keep scrolling to the bottom of fights them for as long as the lab is + running, and there is nothing here that needs to be read in order. + + Options: + limit how many lines to keep. Default 40, clamped to 1..500. + hide event types to leave out. Default ["stat", "status", "netcheck"]. + ============================================================ */ + +const DEFAULT_LIMIT = 40; +const DEFAULT_HIDE = ["stat", "status", "netcheck"]; + +export function create(host) { + const el = host.el; + const feed = host.feed; + const options = host.options || {}; + + const limit = clamp(options.limit, 1, 500, DEFAULT_LIMIT); + const hide = new Set(Array.isArray(options.hide) ? options.hide : DEFAULT_HIDE); + + const list = document.createElement("div"); + list.className = "tape-lines"; + el.appendChild(list); + + /* An empty box reads as a broken one, and with the polls hidden this card + is empty until the lab is asked to do something — which on a freshly + loaded page is a while. So it says what it is waiting for, and the line + goes the moment there is a frame to replace it with. */ + const waiting = document.createElement("div"); + waiting.className = "tape-waiting"; + waiting.textContent = feed + ? "waiting for the lab to do something" + : "no feed on this page"; + list.appendChild(waiting); + + if (!feed) return {}; + + const off = feed.on("*", function (data, ev) { + if (hide.has(ev.type)) return; + if (waiting.parentNode) list.removeChild(waiting); + + const line = document.createElement("div"); + line.className = "tape-line"; + line.dataset.type = ev.type; + + const seq = document.createElement("span"); + seq.className = "tape-seq"; + seq.textContent = pad(ev.seq); + + const type = document.createElement("span"); + type.className = "tape-type"; + type.textContent = ev.type; + + const said = document.createElement("span"); + said.className = "tape-said"; + said.textContent = summarise(ev.type, data); + + line.appendChild(seq); + line.appendChild(type); + line.appendChild(said); + + list.insertBefore(line, list.firstChild); + while (list.childNodes.length > limit) list.removeChild(list.lastChild); + }); + + return { destroy: off }; +} + +/* One line, in the terms the type is about. A frame the reader cannot tell + apart from the one above it is a frame that did not need a line, so every + type says the thing that distinguishes one of its frames from the next. */ +function summarise(type, data) { + const d = data || {}; + switch (type) { + case "hello": + return (d.types ? d.types.length : 0) + " types, " + (d.sources ? d.sources.length : 0) + " sources"; + case "verdict": + return d.id + " — " + (d.result && d.result.ok ? "allowed" : "denied") + + rung(d.result) + " · " + (d.result ? d.result.rule || "" : ""); + case "flow": + return (d.result ? d.result.from + " → " + d.result.to + ":" + d.result.port : d.id) + + " — " + (d.result && d.result.ok ? "reached" : "stopped") + rung(d.result) + + (d.result && d.result.path ? " · " + d.result.path : ""); + case "packet": + case "log": + return clip(d.line, 120); + case "note": + return d.source + ": " + clip(d.line, 100); + case "stat": + return (Array.isArray(data) ? data.length : 0) + " containers sampled"; + case "state": + /* The whole snapshot, and printing any one field of it invites the + reader to think that field is why the frame was sent. It is sent on + change, and which field changed is not in the frame. */ + return "the configuration changed"; + case "status": + case "netcheck": + return clip(oneLine(d.text), 120); + default: + return ""; + } +} + +function rung(res) { + return res && res.rung ? " at rung " + res.rung : ""; +} + +function oneLine(s) { + return typeof s === "string" ? s.replace(/\s+/g, " ").trim() : ""; +} + +function clip(s, n) { + const t = typeof s === "string" ? s : ""; + return t.length > n ? t.slice(0, n - 1) + "…" : t; +} + +function pad(n) { + return typeof n === "number" ? String(n).padStart(4, "0") : "----"; +} + +function clamp(v, lo, hi, def) { + const n = typeof v === "number" ? v : parseInt(v, 10); + if (!isFinite(n)) return def; + return Math.max(lo, Math.min(hi, Math.round(n))); +} diff --git a/lab/control/ui/hud/widgets/tape/widget.json b/lab/control/ui/hud/widgets/tape/widget.json new file mode 100644 index 0000000..d8e540e --- /dev/null +++ b/lab/control/ui/hud/widgets/tape/widget.json @@ -0,0 +1,7 @@ +{ + "id": "tape", + "name": "The tape", + "note": "The feed written down: one line per frame, newest at the top, with the sequence number the server gave it. This is what a recording is made of — what you read here is what a replay will have to work from. The three-second polls are hidden by default so the frames that mean something are not buried; hide: [] puts them back.", + "slots": ["left", "right", "bottom"], + "author": "the guide" +} diff --git a/lab/control/ui/hud/widgets/wire/widget.css b/lab/control/ui/hud/widgets/wire/widget.css new file mode 100644 index 0000000..09f196e --- /dev/null +++ b/lab/control/ui/hud/widgets/wire/widget.css @@ -0,0 +1,46 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* Every rule is scoped to this widget's own card. A widget that styled + anything wider would repaint a page it does not own, and a Go test fails + if one starts to — the same test, and the same reason, as the one that + holds a theme to the nine --board-* properties. */ + +.card[data-widget="wire"] .wire-rows { + display: grid; + grid-template-columns: 1fr auto; + gap: 3px 10px; + align-items: baseline; +} + +.card[data-widget="wire"] .wire-k { + font-size: 11.5px; + color: var(--text-muted); +} + +.card[data-widget="wire"] .wire-v { + font-family: var(--mono); + font-size: 12px; + text-align: right; + color: var(--text); +} + +.card[data-widget="wire"] .wire-v.is-ok { color: var(--ok); } +.card[data-widget="wire"] .wire-v.is-bad { color: var(--danger); } + +/* In a horizontal dock the rows run across rather than down: a top strip is + wide and 40px tall, and a two-column grid in it is a column of pairs with + the whole width to its right unused. */ +.dock-top .card[data-widget="wire"] .wire-rows, +.dock-bottom .card[data-widget="wire"] .wire-rows { + grid-template-columns: none; + grid-auto-flow: column; + grid-template-rows: auto auto; + gap: 0 14px; + align-items: center; +} + +.dock-top .card[data-widget="wire"] .wire-v, +.dock-bottom .card[data-widget="wire"] .wire-v { + text-align: left; +} diff --git a/lab/control/ui/hud/widgets/wire/widget.js b/lab/control/ui/hud/widgets/wire/widget.js new file mode 100644 index 0000000..8f1737d --- /dev/null +++ b/lab/control/ui/hud/widgets/wire/widget.js @@ -0,0 +1,93 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* ============================================================ + The wire. + + Every other widget reads the lab through the feed. This one reads the + feed, which is the reading nothing else on the page does and the one that + says whether to trust the rest. + + The number that matters is `dropped`. The server keeps a bounded queue per + reader and throws frames away rather than blocking the lab on a slow tab, + so a count above zero means there is a hole in what you are watching — and + a drawing that interpolates across a hole is a drawing that is making + things up. It is on a card because a recording made through a gap is worth + knowing about before it is posted, not after. + + Options: none. + ============================================================ */ + +const ROWS = [ + ["link", "the connection"], + ["seq", "frames numbered"], + ["seen", "frames arrived"], + ["drop", "dropped by the server"], + ["src", "sources on the wire"] +]; + +export function create(host) { + const el = host.el; + const feed = host.feed; + + const cell = {}; + const table = document.createElement("div"); + table.className = "wire-rows"; + + for (const [key, label] of ROWS) { + const k = document.createElement("div"); + k.className = "wire-k"; + k.textContent = label; + const v = document.createElement("div"); + v.className = "wire-v"; + v.textContent = "…"; + cell[key] = v; + table.appendChild(k); + table.appendChild(v); + } + el.appendChild(table); + + if (!feed) { + cell.link.textContent = "no feed on this page"; + return {}; + } + + /* Counted here rather than taken from the sequence number, because the two + disagree in the way that is worth seeing: seq is what the server numbered + and `seen` is what arrived. A gap between them, held over several ticks, + is the same story `dropped` tells from the other end. */ + let seen = 0; + const off = feed.on("*", function () { seen++; }); + + function draw() { + const s = feed.status(); + + cell.link.textContent = s.open ? "open" : "closed — retrying"; + cell.link.className = "wire-v " + (s.open ? "is-ok" : "is-bad"); + + cell.seq.textContent = String(s.seq || 0); + cell.seen.textContent = String(seen); + + const dropped = s.dropped || 0; + cell.drop.textContent = dropped ? dropped + " — there is a gap" : "0"; + cell.drop.className = "wire-v " + (dropped ? "is-bad" : "is-ok"); + + /* The server's own list, which is longer than the always-on six whenever + something asked for a capture or a log tail. Before the first hello + there is nothing honest to print. */ + cell.src.textContent = s.sources ? s.sources.join(" · ") : "…"; + } + + draw(); + + /* A second. The polled sources tick every three, so this is fast enough to + make a reconnect visible and slow enough that a card costs nothing. */ + const timer = setInterval(draw, 1000); + + return { + destroy: function () { + clearInterval(timer); + off(); + } + }; +} diff --git a/lab/control/ui/hud/widgets/wire/widget.json b/lab/control/ui/hud/widgets/wire/widget.json new file mode 100644 index 0000000..97707a1 --- /dev/null +++ b/lab/control/ui/hud/widgets/wire/widget.json @@ -0,0 +1,7 @@ +{ + "id": "wire", + "name": "The wire", + "note": "What the feed itself is doing: whether it is open, how far the sequence has got, which sources are on it, and what the server threw away because a reader could not keep up. A dropped count that is not zero means there is a gap in what you are looking at.", + "slots": ["top", "left", "right", "bottom"], + "author": "the guide" +} diff --git a/lab/control/ui/index.html b/lab/control/ui/index.html index cf43595..2dc1b50 100644 --- a/lab/control/ui/index.html +++ b/lab/control/ui/index.html @@ -470,7 +470,8 @@

The disclaimer

import { createBoard, GEOM } from "./hud/scene.js"; import { SETPIECES, run, missing } from "./hud/setpieces.js"; import * as themes from "./hud/themes.js"; - window.LabHUD = { createBoard, GEOM, SETPIECES, run, missing, themes }; + import * as slots from "./hud/slots.js"; + window.LabHUD = { createBoard, GEOM, SETPIECES, run, missing, themes, slots }; window.dispatchEvent(new Event("labhud-ready")); diff --git a/lab/control/ui/style.css b/lab/control/ui/style.css index 2561967..161b21c 100644 --- a/lab/control/ui/style.css +++ b/lab/control/ui/style.css @@ -742,6 +742,96 @@ pre.out.live { max-height: 38vh; overflow-y: auto; } .hud-rung.delivered { border-top-color: var(--ok); background: var(--ok-soft); } .hud-rung.delivered .t { color: var(--ok); font-weight: 600; } +/* --------------------------------------------------------------------------- + Widgets and slots + + Four docks around the board, holding cards a theme asked for. hud/slots.js + builds this layer and removes it again when a theme docks nothing, so there + is no empty element over the board in the ordinary case. + + The layer sits inside the gap the rails leave: --rail-* is measured from + the live rails, the bar and the ladder, so the docks follow a rail being + folded without knowing that rails exist. It takes no pointer events itself + — only the cards do — because a transparent layer over the board is a layer + that would eat every drag aimed at the drawing underneath it. + + --rail-* and not --pad-*, and that is the one thing here worth being + careful about. --pad-* is the same measurement plus what these cards are + covering — it is what moves the gauges and the verdict out of a card's way + — so a dock positioned from it would be a dock that moves every time it is + measured. app.js writes both and says why. + + The docks themselves have no background. A dock with no cards in it is + nothing at all, which is what makes a one-card layout cost the reader one + card's worth of board rather than a frame around the whole window. + --------------------------------------------------------------------------- */ +.slots { + position: absolute; z-index: 22; + inset: calc(var(--rail-t, var(--bar-h)) + var(--hud-gap)) + calc(var(--rail-r, 0px) + var(--hud-gap)) + calc(var(--rail-b, 0px) + var(--hud-gap)) + calc(var(--rail-l, 0px) + var(--hud-gap)); + display: grid; + /* The corners go to the rows: a top strip that spans the full width reads as + a strip, and a left column that starts under it reads as a column. The + alternative — columns owning the corners — makes the top card look like a + third rail with a gap in it. */ + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-rows: auto minmax(0, 1fr) auto; + grid-template-areas: + "top top top" + "left . right" + "bottom bottom bottom"; + gap: var(--hud-gap); + pointer-events: none; +} + +.dock { display: flex; gap: var(--hud-gap); min-width: 0; min-height: 0; } +.dock-top { grid-area: top; } +.dock-bottom { grid-area: bottom; align-items: flex-end; } +.dock-left { grid-area: left; flex-direction: column; } +.dock-right { grid-area: right; flex-direction: column; align-items: flex-end; } + +/* A column dock is as wide as the widest card in it and no wider, and a row + dock is as tall as its tallest. The cards are what have a size; the dock is + where they are. */ +.dock-left, .dock-right { width: max-content; max-width: 300px; } + +.card { + pointer-events: auto; + display: grid; grid-template-rows: auto minmax(0, 1fr); + min-width: 0; min-height: 0; + border-radius: var(--radius); overflow: hidden; +} + +/* A card in a column may be tall; a card in a row may not. The scroll is on + the body so the header stays readable while its content moves, which is the + same arrangement the rails use. */ +.dock-left .card, .dock-right .card { width: 260px; max-height: 100%; } +.dock-top .card, .dock-bottom .card { max-width: 100%; } + +.card-head { + display: flex; align-items: baseline; gap: 6px; + padding: 6px 10px 4px; + border-bottom: 1px solid var(--border); +} +.card-t { + font-family: var(--mono); font-size: 10px; + letter-spacing: .08em; text-transform: uppercase; + color: var(--text-faint); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.card-body { padding: 8px 10px; min-height: 0; overflow: auto; } + +/* A widget that did not load says so where the reader is already looking. + Everything else on this page fails quietly on purpose — a set-piece that + throws must not take the board down — but a card is a box with a name on + it, so an empty one is a visible question already, and the console is a + poor place to answer it. */ +.card-body.card-dead { + font-family: var(--mono); font-size: 11px; color: var(--danger); +} + /* --------------------------------------------------------------------------- The fallback @@ -804,6 +894,24 @@ pre.out.live { max-height: 38vh; overflow-y: auto; } not float, and a pinch on a page this size is the browser's. */ .railtog, .viewpad { display: none; } .hud-ladder { height: auto; } + + /* The cards stack at the foot of the page, for the same reason the rails + stop floating: there is no margin left to overlay honestly. They come + after the readouts because that is where slots.js appends the layer, and + because a card is an extra the reader chose rather than the instrument. */ + .slots { + position: static; inset: auto; display: block; gap: 0; + pointer-events: auto; + } + .dock { display: block; } + .dock-left, .dock-right { width: auto; max-width: none; } + .card { + border-radius: 0; box-shadow: none; backdrop-filter: none; + border-width: 1px 0 0 0; background: var(--bg); + } + .dock-left .card, .dock-right .card, + .dock-top .card, .dock-bottom .card { width: auto; max-width: none; max-height: none; } + .card-body { overflow: visible; } } @media (max-width: 720px) { diff --git a/lab/control/widgets.go b/lab/control/widgets.go new file mode 100644 index 0000000..210b9c0 --- /dev/null +++ b/lab/control/widgets.go @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Hashem Aldhaheri + +package main + +import ( + "encoding/json" + "io/fs" + "log" + "path" + "sort" +) + +// --------------------------------------------------------------------------- +// Widgets and slots +// +// The page has four docks around the board — top, left, right, bottom — and a +// dock holds cards. A card is a widget: a folder under ui/hud/widgets/ with a +// widget.js in it, which is handed a div and the feed and told to draw. +// +// This file is the half that finds them, and it is deliberately the same +// shape as themes.go: walk the embedded UI at startup, skip anything +// malformed with a line at the console, serve what is left at /api/widgets. +// Adding a widget is a folder and a rebuild, not an edit to a list in three +// places — the same property panels.go and the theme store already have. +// +// What goes where is not in here. A widget says which docks it fits in; a +// theme's theme.json says which ones are actually placed and in what order. +// That split is the point of the branch: the page's furniture is data, so a +// theme can rearrange the instrument without touching index.html. +// +// The board is not in this list. It is the floor of the window rather than a +// card in a dock, app.js creates it and drives it by hand, and the slot it +// sits in is host-owned — the layer knows its rectangle so cards stay off it, +// and that is all. Making the board a widget under the same contract as these +// means the board reading the feed instead of being called, which is the next +// piece of work and not this one. A privileged contract that handed a widget +// the twenty-five method board object would freeze hud/scene.js in place, so +// there is no such contract here. +// --------------------------------------------------------------------------- + +// Widget is one entry in the registry, as /api/widgets serves it. +type Widget struct { + ID string `json:"id"` + Name string `json:"name"` + Note string `json:"note"` + // Slots are the docks this widget is willing to sit in. A placement into + // a dock that is not listed is dropped: a tape of every frame on the wire + // wants a tall column and reads as nonsense in a 90px bottom strip, and + // the widget is the thing that knows that. + Slots []string `json:"slots"` + Author string `json:"author"` +} + +// widgetsDir is where the registry lives inside the UI filesystem. Constant +// for the same reason themesDir is: the UI is //go:embed-ed, so nothing can +// put a widget anywhere else without rebuilding the binary. The control +// server holds the Docker socket and has no authentication, which makes +// loading page JavaScript off disk a real risk rather than a theoretical one. +const widgetsDir = "hud/widgets" + +// The docks a widget may ask for. "board" is not one of them — see the note +// at the top of this file. +var validSlots = map[string]bool{ + "top": true, + "left": true, + "right": true, + "bottom": true, +} + +// loadWidgets walks fsys for ui/hud/widgets/*/widget.json and returns what it +// finds, alphabetically by name. +// +// fsys is the UI sub-filesystem, so paths here start at hud/. +func loadWidgets(fsys fs.FS) []Widget { + entries, err := fs.ReadDir(fsys, widgetsDir) + if err != nil { + log.Printf("hud widgets: %s is not readable (%v), so the registry is empty", widgetsDir, err) + return nil + } + + var out []Widget + for _, e := range entries { + if !e.IsDir() { + continue + } + w, ok := readWidget(fsys, e.Name()) + if !ok { + continue + } + out = append(out, w) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// readWidget reads one folder, and says why it skipped it when it does. +// +// The id has to match the folder name because the folder name is what the +// page builds the module URL from. A widget whose json claimed a different id +// would be placed under one name and imported from another, and the failure +// would be an empty card with a 404 in the network tab and nothing in the +// console that named the theme that asked for it. +func readWidget(fsys fs.FS, dir string) (Widget, bool) { + b, err := fs.ReadFile(fsys, path.Join(widgetsDir, dir, "widget.json")) + if err != nil { + log.Printf("hud widget %q: no widget.json, skipped", dir) + return Widget{}, false + } + var w Widget + if err := json.Unmarshal(b, &w); err != nil { + log.Printf("hud widget %q: widget.json does not parse (%v), skipped", dir, err) + return Widget{}, false + } + if w.ID != dir { + log.Printf("hud widget %q: widget.json says id %q, skipped", dir, w.ID) + return Widget{}, false + } + if w.Name == "" { + log.Printf("hud widget %q: no name, skipped", dir) + return Widget{}, false + } + if _, err := fs.Stat(fsys, path.Join(widgetsDir, dir, "widget.js")); err != nil { + log.Printf("hud widget %q: no widget.js, skipped", dir) + return Widget{}, false + } + + // Required, and an empty one is fine. A widget brings its own styling + // because the alternative is style.css carrying a block per widget, which + // puts the host back in the business of knowing what exists — the thing + // this file was written to stop. Requiring the file rather than probing + // for it is what lets slots.js load it unconditionally: a widget the + // server served has a stylesheet, so there is no 404 to explain and no + // optional path to get wrong. + if _, err := fs.Stat(fsys, path.Join(widgetsDir, dir, "widget.css")); err != nil { + log.Printf("hud widget %q: no widget.css, skipped (an empty one is fine)", dir) + return Widget{}, false + } + + // An unknown dock is dropped rather than taken as a reason to drop the + // widget: a widget listing "sidebar" alongside "left" is a widget written + // against a slot name this build does not have, and the half of it that + // still makes sense is worth keeping. + var slots []string + for _, s := range w.Slots { + if !validSlots[s] { + log.Printf("hud widget %q: slot %q is not a dock, ignored", dir, s) + continue + } + slots = append(slots, s) + } + if len(slots) == 0 { + log.Printf("hud widget %q: no slot it fits in, skipped", dir) + return Widget{}, false + } + w.Slots = slots + + return w, true +} + +// resolvePlacements drops every placement that cannot be honoured, and says +// which and why. +// +// A theme is parsed without knowing what is in the registry — themes.go can +// tell that a slot name is wrong, but not that a widget is missing — so this +// is the pass that puts the two halves together. It runs once at startup, +// which means the layout the page is handed is already true: the page mounts +// what it is given and never has to decide what to do about a card it cannot +// build. +// +// The themes are returned rather than mutated in place so the caller's +// assignment is where the pruning visibly happens. +func resolvePlacements(themes []Theme, widgets []Widget) []Theme { + fits := map[string]map[string]bool{} + for _, w := range widgets { + slots := map[string]bool{} + for _, s := range w.Slots { + slots[s] = true + } + fits[w.ID] = slots + } + + out := make([]Theme, 0, len(themes)) + for _, th := range themes { + var kept []Placement + for _, p := range th.Layout { + slots, known := fits[p.Widget] + if !known { + log.Printf("hud theme %q: widget %q is not embedded, placement dropped", th.ID, p.Widget) + continue + } + if !slots[p.Slot] { + log.Printf("hud theme %q: widget %q does not fit the %q dock, placement dropped", th.ID, p.Widget, p.Slot) + continue + } + kept = append(kept, p) + } + th.Layout = kept + out = append(out, th) + } + return out +} diff --git a/lab/control/widgets_test.go b/lab/control/widgets_test.go new file mode 100644 index 0000000..e48f4f9 --- /dev/null +++ b/lab/control/widgets_test.go @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Hashem Aldhaheri + +package main + +import ( + "io/fs" + "strings" + "testing" + "testing/fstest" +) + +// Same split as themes_test.go: the questions of the form "is what shipped +// well-formed" run against the real embedded folders, and the questions about +// how a malformed one is handled run against a synthetic tree, because the +// embedded one is — and should stay — correct. + +func TestEmbeddedWidgetsAreWellFormed(t *testing.T) { + got := loadWidgets(embeddedUI(t)) + if len(got) == 0 { + t.Fatal("no widgets were embedded; ui/hud/widgets should hold at least one") + } + + seen := map[string]bool{} + for _, w := range got { + if seen[w.ID] { + t.Errorf("widget %q appears twice", w.ID) + } + seen[w.ID] = true + + if w.Name == "" { + t.Errorf("widget %q has no name, so its card would have an empty header", w.ID) + } + // The note is the only thing somebody browsing the registry to write + // a theme has to go on. + if w.Note == "" { + t.Errorf("widget %q has no note", w.ID) + } + if len(w.Slots) == 0 { + t.Errorf("widget %q fits in no dock, so nothing could place it", w.ID) + } + for _, s := range w.Slots { + if !validSlots[s] { + t.Errorf("widget %q claims slot %q, which is not a dock", w.ID, s) + } + } + } +} + +// The seam, asserted. A widget brings its own stylesheet, which is what keeps +// style.css from carrying a block per widget — and the price of that is that a +// widget could repaint the whole page from inside its own folder. Every +// selector is anchored to the widget's own card, and this is what says so. +// +// It is the same test, for the same reason, as the one holding a theme to the +// nine --board-* properties: the rule is only a rule while something fails +// when it is broken. +func TestWidgetStylesheetsAreScopedToTheirOwnCard(t *testing.T) { + ui := embeddedUI(t) + for _, w := range loadWidgets(ui) { + b, err := fs.ReadFile(ui, widgetsDir+"/"+w.ID+"/widget.css") + if err != nil { + t.Errorf("widget %q: reading widget.css: %v", w.ID, err) + continue + } + anchor := `[data-widget="` + w.ID + `"]` + for _, sel := range selectors(string(b)) { + if !strings.Contains(sel, anchor) { + t.Errorf("widget %q: selector %q is not anchored to %s, so it styles a page it does not own", + w.ID, sel, anchor) + } + } + } +} + +// Every placement in every embedded theme builds something. +// +// This is the check that matters most in this file, because the failure it +// catches is silent in a browser: a theme naming a widget that is not there +// gets a layout with that card quietly missing, and the reader has no way to +// tell that from a theme that never asked for it. Running the real loaders +// against the real folders is the only way to know that what shipped agrees +// with itself. +func TestEmbeddedThemeLayoutsAllResolve(t *testing.T) { + ui := embeddedUI(t) + widgets := loadWidgets(ui) + asked := loadThemes(ui) + kept := resolvePlacements(asked, widgets) + + if len(asked) != len(kept) { + t.Fatalf("resolvePlacements returned %d themes, want the %d it was given", len(kept), len(asked)) + } + for i := range asked { + if len(asked[i].Layout) == len(kept[i].Layout) { + continue + } + t.Errorf("theme %q asked for %d cards and only %d can be built; the console says which", + asked[i].ID, len(asked[i].Layout), len(kept[i].Layout)) + } +} + +// The base theme docks nothing, for the same reason its stylesheet overrides +// nothing: it is the page as it was before there was a store, and it is where +// a saved id falls back to. A fallback that rearranges the furniture is not a +// fallback. +func TestBaseThemeDocksNothing(t *testing.T) { + for _, th := range loadThemes(embeddedUI(t)) { + if th.ID != baseTheme { + continue + } + if len(th.Layout) != 0 { + t.Errorf("%q docks %d cards; it is meant to be the page with no cards on it", + baseTheme, len(th.Layout)) + } + return + } + t.Fatalf("%q was not found", baseTheme) +} + +// --------------------------------------------------------------------------- +// What happens to a malformed one +// --------------------------------------------------------------------------- + +func TestWidgetIDMustMatchItsFolder(t *testing.T) { + got := loadWidgets(widgetTree(map[string]string{ + "good": `{"id":"good","name":"Good","slots":["left"]}`, + "liar": `{"id":"somethingelse","name":"Liar","slots":["left"]}`, + })) + if len(got) != 1 || got[0].ID != "good" { + t.Fatalf("got %v, want only the folder whose id matches it", got) + } +} + +func TestWidgetWithoutItsFilesIsSkipped(t *testing.T) { + tree := fstest.MapFS{ + widgetsDir + "/nojs/widget.json": {Data: []byte(`{"id":"nojs","name":"No JS","slots":["left"]}`)}, + widgetsDir + "/nojs/widget.css": {Data: []byte("")}, + widgetsDir + "/nocss/widget.json": {Data: []byte(`{"id":"nocss","name":"No CSS","slots":["left"]}`)}, + widgetsDir + "/nocss/widget.js": {Data: []byte("")}, + } + if got := loadWidgets(tree); len(got) != 0 { + t.Fatalf("got %v, want nothing: the page would ask for a file that is not there", got) + } +} + +func TestUnparseableWidgetIsSkippedNotFatal(t *testing.T) { + got := loadWidgets(widgetTree(map[string]string{ + "broken": `{ this is not json`, + "fine": `{"id":"fine","name":"Fine","slots":["left"]}`, + })) + if len(got) != 1 || got[0].ID != "fine" { + t.Fatalf("got %v, want the one good widget: a broken extra must not take the registry with it", got) + } +} + +// An unknown dock is dropped from the list and the widget is kept, because a +// widget listing one slot this build does not have and one it does is still +// placeable in the one it does. A widget left with no slot at all is dropped: +// nothing could ever place it, and keeping it would put a row in the registry +// that is an invitation to a placement that always fails. +func TestUnknownSlotIsDroppedAndAnEmptyListSkipsTheWidget(t *testing.T) { + got := loadWidgets(widgetTree(map[string]string{ + "half": `{"id":"half","name":"Half","slots":["sidebar","left"]}`, + "none": `{"id":"none","name":"None","slots":["sidebar"]}`, + })) + if len(got) != 1 || got[0].ID != "half" { + t.Fatalf("got %v, want only the widget with a real slot left", got) + } + if len(got[0].Slots) != 1 || got[0].Slots[0] != "left" { + t.Errorf("slots = %v, want just the one this build has", got[0].Slots) + } +} + +// A placement aimed at a slot that is not a dock cannot become a card however +// the registry turns out, so themes.go drops it while reading the folder. +func TestThemeLayoutDropsAnUnknownSlot(t *testing.T) { + tree := fstest.MapFS{ + themesDir + "/t/theme.json": {Data: []byte(`{"id":"t","name":"T","ground":"any","layout":[ + {"widget":"wire","slot":"left"}, + {"widget":"wire","slot":"middle"}, + {"widget":"","slot":"left"} + ]}`)}, + themesDir + "/t/theme.css": {Data: []byte("")}, + } + got := loadThemes(tree) + if len(got) != 1 { + t.Fatalf("got %v, want the theme kept", got) + } + if len(got[0].Layout) != 1 || got[0].Layout[0].Slot != "left" { + t.Fatalf("layout = %v, want only the placement aimed at a real dock", got[0].Layout) + } +} + +// The other half of the same question: the slot is real, but the widget is not +// embedded or does not fit the dock that was asked for. +func TestResolvePlacementsDropsWhatCannotBeBuilt(t *testing.T) { + widgets := []Widget{{ID: "tape", Name: "Tape", Slots: []string{"left", "right"}}} + themes := []Theme{{ID: "t", Name: "T", Layout: []Placement{ + {Widget: "tape", Slot: "left"}, + {Widget: "tape", Slot: "top"}, // real widget, wrong dock + {Widget: "ghost", Slot: "left"}, // not embedded + }}} + + got := resolvePlacements(themes, widgets) + if len(got) != 1 { + t.Fatalf("got %d themes, want 1", len(got)) + } + if len(got[0].Layout) != 1 { + t.Fatalf("layout = %v, want only the placement that can be built", got[0].Layout) + } + if got[0].Layout[0].Slot != "left" { + t.Errorf("kept %q, want the left one", got[0].Layout[0].Slot) + } + + // The themes it was handed are not modified, so a caller that kept a + // reference to the unpruned list still has one. + if len(themes[0].Layout) != 3 { + t.Errorf("the input theme was mutated: layout is now %v", themes[0].Layout) + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// widgetTree builds a registry from id -> widget.json, with the two required +// files present so the only thing under test is the manifest. +func widgetTree(manifests map[string]string) fstest.MapFS { + tree := fstest.MapFS{} + for dir, json := range manifests { + tree[widgetsDir+"/"+dir+"/widget.json"] = &fstest.MapFile{Data: []byte(json)} + tree[widgetsDir+"/"+dir+"/widget.js"] = &fstest.MapFile{Data: []byte("")} + tree[widgetsDir+"/"+dir+"/widget.css"] = &fstest.MapFile{Data: []byte("")} + } + return tree +} + +// selectors returns every selector in a stylesheet: the text before each +// opening brace, split on commas and trimmed. +// +// At-rules are skipped rather than checked — `@media (max-width: 600px)` is +// not a selector and the selectors inside it are returned on their own, which +// is what the caller wants to look at. Declarations are skipped by taking only +// the text after the last `;` or `}`, which is where a selector starts. +func selectors(css string) []string { + var out []string + rest := stripCSSComments(css) + + for { + open := strings.Index(rest, "{") + if open < 0 { + return out + } + head := rest[:open] + rest = rest[open+1:] + + if cut := strings.LastIndexAny(head, ";}"); cut >= 0 { + head = head[cut+1:] + } + for _, sel := range strings.Split(head, ",") { + sel = strings.TrimSpace(sel) + if sel == "" || strings.HasPrefix(sel, "@") { + continue + } + out = append(out, sel) + } + } +} + +func stripCSSComments(s string) string { + var b strings.Builder + for { + start := strings.Index(s, "/*") + if start < 0 { + b.WriteString(s) + return b.String() + } + b.WriteString(s[:start]) + end := strings.Index(s[start+2:], "*/") + if end < 0 { + return b.String() + } + s = s[start+2+end+2:] + } +}