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
57 changes: 56 additions & 1 deletion lab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1282,9 +1282,64 @@ lab/
├── three.module.js three.js r166, MIT, inside the binary
├── scene.js the board: five gates, three planes, the packet
├── reading.js what a Result says — pure, and the only tested part
└── setpieces.js one exported function per attack id
├── 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
```

### Themes for the board

The board has never carried a palette. `readTokens()` in `hud/scene.js` reads
nine CSS custom properties off the page and paints from what it read, which is
how the light/dark button moves the drawing with it. A theme is that mechanism,
used deliberately: redefine some of the nine, and tell the board they moved.

One theme is one folder under `control/ui/hud/themes/`:

```
themes/warroom/
├── theme.json id, name, note, ground, author
└── theme.css the nine, scoped to :root[data-hud-theme="warroom"]
```

`themes.go` walks the embedded UI at startup and serves what it found at
`/api/themes`, so the picker in the header is populated from the server rather
than from a list in the markup. Adding a theme is a folder and a rebuild.
Nothing else is edited.

The nine are declared in `style.css` under **the board's palette**, each
defaulting to the page token it replaced:

| Property | What it paints |
|---|---|
| `--board-accent` | the tailnet, the gate posts, anything the lab owns |
| `--board-danger` | a rule that refused, and the attacker |
| `--board-warn` | an outcome that proved nothing either way |
| `--board-ok` | a rung that held |
| `--board-line` | gate posts, the ground grid, the rails |
| `--board-text` | machine labels |
| `--board-muted` | secondary labels |
| `--board-faint` | addresses, and anything standing down |
| `--board-card` | the plane the machines stand on |

Those nine are the whole surface. A theme that sets anything else reaches past
the drawing and repaints the panels around it, which is not what a board theme
is for — and `hud/themes/chapter` is the proof the seam is real: it overrides
nothing, and a test fails if it ever starts to.

`ground` in `theme.json` is `light`, `dark` or `any`. Selecting a theme that
names one moves the page to it; the light/dark button still wins afterwards,
because a reader who presses it has said something more recent than the
manifest did.

A theme that does not parse, or whose `id` disagrees with its folder name, is
skipped with a line at the console and the rest of the store still loads. The
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.

### If you edit the UI, rebuild

`main.go` does `//go:embed ui`, so the page you get on :8099 is the copy
Expand Down
1 change: 1 addition & 0 deletions lab/checks/syntax.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const FILES = [
["lab/control/ui/hud/scene.js", "module"],
["lab/control/ui/hud/setpieces.js", "module"],
["lab/control/ui/hud/reading.js", "module"],
["lab/control/ui/hud/themes.js", "module"],
["assets/app.js", "commonjs"],
["assets/anim.js", "commonjs"],
["assets/nav.js", "commonjs"],
Expand Down
19 changes: 19 additions & 0 deletions lab/control/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,11 @@ 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)

writeJSON := func(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
Expand Down Expand Up @@ -544,6 +549,20 @@ func routes(c *Controller) http.Handler {
})
})

// The HUD theme store: which palettes were embedded under ui/hud/themes.
// Separate from /api/meta because it describes the page rather than the
// lab, and because a reader adding a theme should not have to read a
// payload about machines and attacks to find out whether it was picked up.
mux.HandleFunc("/api/themes", func(w http.ResponseWriter, r *http.Request) {
// Never null: the page treats a non-array as an empty store, and an
// empty store is a picker with one option rather than a broken one.
if themes == nil {
writeJSON(w, []Theme{})
return
}
writeJSON(w, themes)
})

mux.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) {
c.Observe(r.Context())
writeJSON(w, c.Snapshot())
Expand Down
134 changes: 134 additions & 0 deletions lab/control/themes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 Hashem Aldhaheri

package main

import (
"encoding/json"
"io/fs"
"log"
"path"
"sort"
)

// ---------------------------------------------------------------------------
// HUD themes
//
// The board has always read its colours off the page rather than carrying a
// palette of its own — readTokens() in hud/scene.js pulls nine custom
// properties and paints from them. That was done so the light/dark button
// could move the board with it, and it is the whole mechanism a theme needs:
// redefine the nine, tell the board they moved.
//
// So a theme is a folder under ui/hud/themes/ with two files in it. theme.css
// redefines whichever of the nine it cares about, scoped to
// :root[data-hud-theme="<id>"] so it is inert until selected. theme.json says
// what it is called and which ground it was drawn for.
//
// This file is the half that finds them. It walks the embedded UI at startup
// so the page does not have to be told what exists — adding a theme is a
// folder and a rebuild, not an edit to a list in three places. Which is the
// same reason panels.go exists: the server holds the spec, the UI renders
// whatever it is handed.
//
// A theme that does not parse, or whose id disagrees with its folder, is
// skipped with a line at the console rather than taken as a reason to refuse
// to start. That is the choice setpieces.go's missing() already makes and for
// the same reason: a broken extra must not take the lab with it, and a silent
// one is how `rotate-key` went a release with nothing drawn.
// ---------------------------------------------------------------------------

// Theme is one entry in the store, as /api/themes serves it.
type Theme struct {
ID string `json:"id"`
Name string `json:"name"`
Note string `json:"note"`
// Ground is the page background the theme was drawn against: "light",
// "dark", or "any" for one that works on either. It is advisory — selecting
// a theme that names a ground moves the page to it, and the light/dark
// button still wins afterwards.
Ground string `json:"ground"`
Author string `json:"author"`
}

// themesDir is where the store lives inside the UI filesystem. It is a
// constant rather than a flag because the UI is //go:embed-ed: nothing can put
// a theme anywhere else without rebuilding the binary, which is the point.
const themesDir = "hud/themes"

// baseTheme is the one that must exist. It overrides nothing — it is the
// guide's own palette, which is what the board looked like before there was a
// store — so it is also the fallback when a saved id no longer resolves.
const baseTheme = "chapter"

var validGrounds = map[string]bool{"light": true, "dark": true, "any": true}

// loadThemes walks fsys for ui/hud/themes/*/theme.json and returns what it
// finds, base theme first and the rest alphabetically by name.
//
// fsys is the UI sub-filesystem, so paths here start at hud/.
func loadThemes(fsys fs.FS) []Theme {
entries, err := fs.ReadDir(fsys, themesDir)
if err != nil {
log.Printf("hud themes: %s is not readable (%v), so the store is empty", themesDir, err)
return nil
}

var out []Theme
for _, e := range entries {
if !e.IsDir() {
continue
}
t, ok := readTheme(fsys, e.Name())
if !ok {
continue
}
out = append(out, t)
}

sort.Slice(out, func(i, j int) bool {
if (out[i].ID == baseTheme) != (out[j].ID == baseTheme) {
return out[i].ID == baseTheme
}
return out[i].Name < out[j].Name
})
return out
}

// readTheme 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 stylesheet URL from. A theme whose json claimed a different
// id would load one file and select another, and the failure would be a board
// that keeps the previous palette with no error anywhere.
func readTheme(fsys fs.FS, dir string) (Theme, bool) {
b, err := fs.ReadFile(fsys, path.Join(themesDir, dir, "theme.json"))
if err != nil {
log.Printf("hud theme %q: no theme.json, skipped", dir)
return Theme{}, false
}
var t Theme
if err := json.Unmarshal(b, &t); err != nil {
log.Printf("hud theme %q: theme.json does not parse (%v), skipped", dir, err)
return Theme{}, false
}
if t.ID != dir {
log.Printf("hud theme %q: theme.json says id %q, skipped", dir, t.ID)
return Theme{}, false
}
if t.Name == "" {
log.Printf("hud theme %q: no name, skipped", dir)
return Theme{}, false
}
if _, err := fs.Stat(fsys, path.Join(themesDir, dir, "theme.css")); err != nil {
log.Printf("hud theme %q: no theme.css, skipped", dir)
return Theme{}, false
}
if !validGrounds[t.Ground] {
// Not a reason to drop the theme: "any" is the honest reading of a
// theme that did not say, and it is what the base theme is.
log.Printf("hud theme %q: ground %q is not light, dark or any — read as any", dir, t.Ground)
t.Ground = "any"
}
return t, true
}
Loading
Loading