Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 78 additions & 3 deletions lab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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="<id>"]`, 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
Expand Down
36 changes: 34 additions & 2 deletions lab/checks/syntax.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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) {
Expand Down
26 changes: 22 additions & 4 deletions lab/control/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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())
Expand Down
46 changes: 46 additions & 0 deletions lab/control/themes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
87 changes: 86 additions & 1 deletion lab/control/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
Expand Down Expand Up @@ -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);
}

Expand Down
Loading
Loading