diff --git a/lab/README.md b/lab/README.md index 00a2c2b..5e7d26d 100644 --- a/lab/README.md +++ b/lab/README.md @@ -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 diff --git a/lab/checks/syntax.mjs b/lab/checks/syntax.mjs index e43f5fe..0969bdb 100644 --- a/lab/checks/syntax.mjs +++ b/lab/checks/syntax.mjs @@ -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"], diff --git a/lab/control/main.go b/lab/control/main.go index d93fc39..0bf8d2e 100644 --- a/lab/control/main.go +++ b/lab/control/main.go @@ -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) @@ -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()) diff --git a/lab/control/themes.go b/lab/control/themes.go new file mode 100644 index 0000000..ab1c897 --- /dev/null +++ b/lab/control/themes.go @@ -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=""] 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 +} diff --git a/lab/control/themes_test.go b/lab/control/themes_test.go new file mode 100644 index 0000000..a212776 --- /dev/null +++ b/lab/control/themes_test.go @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Hashem Aldhaheri + +package main + +import ( + "io/fs" + "testing" + "testing/fstest" +) + +// The store is read off the embedded UI, so these run against the real +// folders rather than a fixture wherever the question is "is what shipped +// well-formed". A theme added with a typo in its id, or without the +// stylesheet the page will ask for, is exactly the failure this catches — and +// it is silent in a browser, because a stylesheet that 404s leaves the board +// painting the palette it already had. + +func embeddedUI(t *testing.T) fs.FS { + t.Helper() + ui, err := fs.Sub(uiFS, "ui") + if err != nil { + t.Fatalf("embedded UI: %v", err) + } + return ui +} + +func TestEmbeddedThemesAreWellFormed(t *testing.T) { + got := loadThemes(embeddedUI(t)) + if len(got) == 0 { + t.Fatal("no themes were embedded; ui/hud/themes should hold at least " + baseTheme) + } + + seen := map[string]bool{} + for _, th := range got { + if seen[th.ID] { + t.Errorf("theme %q appears twice", th.ID) + } + seen[th.ID] = true + + if th.Name == "" { + t.Errorf("theme %q has no name, so the picker would show an empty row", th.ID) + } + if !validGrounds[th.Ground] { + t.Errorf("theme %q has ground %q, want light, dark or any", th.ID, th.Ground) + } + // The note is what the picker puts in its title attribute. A theme + // with none is not broken, but it is the one thing a reader browsing + // the store has to go on. + if th.Note == "" { + t.Errorf("theme %q has no note", th.ID) + } + } + + if !seen[baseTheme] { + t.Errorf("%q was not found; it is the fallback and has to exist", baseTheme) + } +} + +// The base theme is the one that must override nothing: it is what the board +// looked like before the store, and it is where a saved id falls back to. A +// stylesheet with declarations in it would make "chapter" a palette of its own +// and quietly fork the nine colours away from style.css. +func TestBaseThemeOverridesNothing(t *testing.T) { + b, err := fs.ReadFile(embeddedUI(t), themesDir+"/"+baseTheme+"/theme.css") + if err != nil { + t.Fatalf("reading the base theme: %v", err) + } + if stripComments(string(b)) != "" { + t.Errorf("%s/theme.css declares something; it is meant to override nothing:\n%s", + baseTheme, stripComments(string(b))) + } +} + +// stripComments answers one question about a stylesheet: does it set anything? +// It strips /* ... */ and then looks for a colon inside a block, which is what +// a declaration is and what a selector's pseudo-class is not — :root carries a +// colon too, and it is outside the braces. +// +// It returns the offending text rather than a bool so the failure message can +// show what was found. Empty means the sheet declares nothing. +func stripComments(s string) string { + var out []rune + in := []rune(s) + for i := 0; i < len(in); i++ { + if i+1 < len(in) && in[i] == '/' && in[i+1] == '*' { + for i += 2; i+1 < len(in) && !(in[i] == '*' && in[i+1] == '/'); i++ { + } + i++ + continue + } + out = append(out, in[i]) + } + + depth, start := 0, -1 + for i, r := range out { + switch r { + case '{': + depth++ + case '}': + depth-- + case ':': + if depth > 0 && start < 0 { + start = i + } + } + } + if start < 0 { + return "" + } + return string(out[start:min(start+60, len(out))]) +} + +// A folder whose json disagrees with it would have the page load one +// stylesheet and select a different attribute value, which is a board that +// keeps its old colours and says nothing. Checked against a synthetic tree +// because the embedded one is — and should stay — correct. +func TestThemeIDMustMatchItsFolder(t *testing.T) { + tree := fstest.MapFS{ + themesDir + "/good/theme.json": {Data: []byte(`{"id":"good","name":"Good","ground":"dark"}`)}, + themesDir + "/good/theme.css": {Data: []byte("")}, + themesDir + "/liar/theme.json": {Data: []byte(`{"id":"somethingelse","name":"Liar","ground":"dark"}`)}, + themesDir + "/liar/theme.css": {Data: []byte("")}, + } + got := loadThemes(tree) + if len(got) != 1 || got[0].ID != "good" { + t.Fatalf("got %v, want only the folder whose id matches it", got) + } +} + +func TestThemeWithoutStylesheetIsSkipped(t *testing.T) { + tree := fstest.MapFS{ + themesDir + "/nocss/theme.json": {Data: []byte(`{"id":"nocss","name":"No CSS","ground":"any"}`)}, + } + if got := loadThemes(tree); len(got) != 0 { + t.Fatalf("got %v, want nothing: the page would ask for a theme.css that is not there", got) + } +} + +func TestUnparseableThemeIsSkippedNotFatal(t *testing.T) { + tree := fstest.MapFS{ + themesDir + "/broken/theme.json": {Data: []byte(`{ this is not json`)}, + themesDir + "/broken/theme.css": {Data: []byte("")}, + themesDir + "/fine/theme.json": {Data: []byte(`{"id":"fine","name":"Fine","ground":"any"}`)}, + themesDir + "/fine/theme.css": {Data: []byte("")}, + } + got := loadThemes(tree) + if len(got) != 1 || got[0].ID != "fine" { + t.Fatalf("got %v, want the one good theme: a broken extra must not take the store with it", got) + } +} + +// An unrecognised ground is read as "any" rather than dropping the theme. +// Selecting it then leaves the page's own light/dark setting alone, which is +// the harmless reading. +func TestUnknownGroundIsReadAsAny(t *testing.T) { + tree := fstest.MapFS{ + themesDir + "/odd/theme.json": {Data: []byte(`{"id":"odd","name":"Odd","ground":"chartreuse"}`)}, + themesDir + "/odd/theme.css": {Data: []byte("")}, + } + got := loadThemes(tree) + if len(got) != 1 { + t.Fatalf("got %v, want the theme kept", got) + } + if got[0].Ground != "any" { + t.Errorf("ground = %q, want any", got[0].Ground) + } +} + +// The base theme sorts first because it is the one a reader falls back to and +// the one the picker opens on; the rest are alphabetical by the name shown, +// not by the id, which is what somebody scanning the list is reading. +func TestBaseThemeSortsFirst(t *testing.T) { + tree := fstest.MapFS{ + themesDir + "/aaa/theme.json": {Data: []byte(`{"id":"aaa","name":"Aaa","ground":"any"}`)}, + themesDir + "/aaa/theme.css": {Data: []byte("")}, + themesDir + "/" + baseTheme + "/theme.json": {Data: []byte(`{"id":"` + baseTheme + `","name":"Zzz","ground":"any"}`)}, + themesDir + "/" + baseTheme + "/theme.css": {Data: []byte("")}, + } + got := loadThemes(tree) + if len(got) != 2 { + t.Fatalf("got %v, want two", got) + } + if got[0].ID != baseTheme { + t.Errorf("first = %q, want %q even though its name sorts last", got[0].ID, baseTheme) + } +} diff --git a/lab/control/ui/app.js b/lab/control/ui/app.js index 2afd1f5..b17a4cb 100644 --- a/lab/control/ui/app.js +++ b/lab/control/ui/app.js @@ -37,6 +37,41 @@ if (board) board.refreshTokens(); }); + /* ---- the board's palette --------------------------------------- + A second axis, and not the same one. The button above is the page's + ground; this is which of the embedded HUD themes paints the drawing. + hud/themes.js owns both halves — what exists, and what is selected — + because the server is what knows which folders were embedded. + + The picker is populated from the answer rather than from markup, so a + theme added to hud/themes/ appears here after a rebuild with nothing + else edited. index.html ships one "; + }).join(""); + sel.value = applied; + sel.title = (api.get(applied) || {}).note || ""; + }); + + sel.addEventListener("change", function () { + api.apply(sel.value, board).then(function (applied) { + sel.value = applied; + sel.title = (api.get(applied) || {}).note || ""; + }); + }); + } + /* ---- 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. @@ -136,6 +171,9 @@ boardBroken = true; $("#view-note").textContent = "the board needs WebGL, and this browser did not give it one"; setView("flat"); + /* Still worth selecting: the picker is the reader's setting, not the + board's, and it has to survive a session that never got a board. */ + startThemes(); return; } if (meta) applyMeta(); @@ -147,6 +185,7 @@ ordinary case: the layout settles long before WebGL does. */ measureRails(); syncZoom(); + startThemes(); } if (window.LabHUD) startBoard(); diff --git a/lab/control/ui/hud/scene.js b/lab/control/ui/hud/scene.js index b0023c6..fdbd1a3 100644 --- a/lab/control/ui/hud/scene.js +++ b/lab/control/ui/hud/scene.js @@ -121,15 +121,21 @@ export function createBoard(canvas, hud) { function readTokens() { const cs = getComputedStyle(document.documentElement); const col = (n) => new THREE.Color(cs.getPropertyValue(n).trim() || "#888"); - C.accent = col("--accent"); - C.danger = col("--danger"); - C.warn = col("--warn"); - C.ok = col("--ok"); - C.line = col("--border-strong"); - C.text = col("--text"); - C.muted = col("--text-muted"); - C.faint = col("--text-faint"); - C.card = col("--bg-raised"); + /* The nine --board-* properties, not the page tokens they used to be. + They are declared in style.css under "the board's palette" and each + defaults to the page token this line used to name, so the light/dark + button still moves the board and nothing about this call changed — + except that a theme under hud/themes/ now has somewhere to stand + between the two. */ + C.accent = col("--board-accent"); + C.danger = col("--board-danger"); + C.warn = col("--board-warn"); + C.ok = col("--board-ok"); + C.line = col("--board-line"); + C.text = col("--board-text"); + C.muted = col("--board-muted"); + C.faint = col("--board-faint"); + C.card = col("--board-card"); } readTokens(); diff --git a/lab/control/ui/hud/themes.js b/lab/control/ui/hud/themes.js new file mode 100644 index 0000000..153d614 --- /dev/null +++ b/lab/control/ui/hud/themes.js @@ -0,0 +1,145 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* ============================================================ + The HUD theme store. + + A theme is a folder under hud/themes/ holding theme.css and theme.json. + themes.go walks the embedded UI and serves what it found at /api/themes, + so nothing here carries a list: the server knows, and this asks. + + Selecting a theme does three things and no more. + + 1. Make sure the theme's stylesheet is in the document. Every theme's + rules are scoped to :root[data-hud-theme=""], so a sheet that has + been loaded and then deselected is inert rather than removed. Loading + is one-way, which keeps switching back instant and keeps this file + free of the question of when a is safe to drop. + + 2. Set data-hud-theme on the root element, and — if the theme named a + ground — move data-theme to it. Advisory, as themes.go says: the + light/dark button still wins afterwards, because a reader who presses + it has said something more recent than the theme's manifest did. + + 3. Tell the board its colours moved. scene.js reads the nine --board-* + properties once and paints from what it read, so nothing changes on + screen until refreshTokens() is called. This is the same call the + light/dark button already makes. + + Every entry point survives having no board, no server and no localStorage, + because the page it belongs to does. A control surface that will not draw + because a stylesheet 404'd is a worse failure than an unthemed board. + ============================================================ */ + +const BASE = "chapter"; /* matches baseTheme in themes.go */ +const STORE_KEY = "lab-hud-theme"; + +/* Which stylesheets have been put in the document, by id. */ +const loaded = new Set(); + +let catalog = []; +let active = BASE; + +/* ---------- what exists ---------------------------------------- */ + +/* The store, as the server found it. Empty until load() has run, which is + the honest answer before then rather than a guess. */ +export function list() { return catalog.slice(); } + +/* The id currently selected. */ +export function current() { return active; } + +/* One entry by id, or null. */ +export function get(id) { + for (const t of catalog) if (t.id === id) return t; + return null; +} + +/* Ask the server what it embedded. A failure here is not fatal: the base + theme overrides nothing, so a page with no catalog still draws the board + exactly as it did before this file existed. */ +export async function load() { + try { + const res = await fetch("/api/themes", { headers: { Accept: "application/json" } }); + if (!res.ok) throw new Error("HTTP " + res.status); + const body = await res.json(); + catalog = Array.isArray(body) ? body : []; + } catch (e) { + console.warn("hud themes: /api/themes did not answer (" + e.message + "), staying on " + BASE); + catalog = []; + } + return list(); +} + +/* ---------- selecting one -------------------------------------- */ + +/* Put a theme's stylesheet in the document, and resolve when the browser has + parsed it. The wait matters: refreshTokens() reads computed values, and a + board told to repaint before its palette landed paints the old one and has + no reason to try again. + + A sheet that fails to load resolves too. The rules simply never apply, the + board keeps the palette it had, and the console says which file was + missing — which is a readable board and a legible complaint, rather than a + promise that never settles. */ +function ensureSheet(id) { + if (loaded.has(id)) return Promise.resolve(); + return new Promise((resolve) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "hud/themes/" + encodeURIComponent(id) + "/theme.css"; + link.dataset.hudTheme = id; + link.addEventListener("load", () => { loaded.add(id); resolve(); }); + link.addEventListener("error", () => { + console.warn("hud theme " + id + ": theme.css did not load, the board keeps its colours"); + /* Marked loaded on purpose: retrying on every switch would add one dead + per press for as long as the file is missing. */ + loaded.add(id); + resolve(); + }); + document.head.appendChild(link); + }); +} + +/* Select a theme. `board` may be null — the page runs without one whenever + WebGL is unavailable, and the attribute is still worth setting so anything + else styled off it agrees with the picker. */ +export async function apply(id, board) { + const theme = get(id) || get(BASE); + const next = theme ? theme.id : BASE; + + await ensureSheet(next); + document.documentElement.setAttribute("data-hud-theme", next); + + if (theme && (theme.ground === "light" || theme.ground === "dark")) { + document.documentElement.setAttribute("data-theme", theme.ground); + try { localStorage.setItem("lab-theme", theme.ground); } catch (e) { /* private mode */ } + } + + active = next; + try { localStorage.setItem(STORE_KEY, next); } catch (e) { /* private mode */ } + + if (board) board.refreshTokens(); + return next; +} + +/* ---------- starting up ---------------------------------------- */ + +/* Read the catalog, then select whatever was chosen last — falling back to + the base theme when the saved id names a theme that is no longer embedded, + which is what happens the first time somebody removes a folder. + + Returns the id that ended up applied, so a caller can set its picker from + the answer rather than from what it asked for. */ +export async function init(board) { + await load(); + + let saved = null; + try { saved = localStorage.getItem(STORE_KEY); } catch (e) { /* private mode */ } + + if (saved && !get(saved)) { + console.warn("hud themes: " + saved + " is no longer embedded, falling back to " + BASE); + saved = null; + } + return apply(saved || BASE, board); +} diff --git a/lab/control/ui/hud/themes/chapter/theme.css b/lab/control/ui/hud/themes/chapter/theme.css new file mode 100644 index 0000000..acd17a7 --- /dev/null +++ b/lab/control/ui/hud/themes/chapter/theme.css @@ -0,0 +1,23 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* Chapter — the board as the guide draws it. + + This file overrides nothing, and that is the whole of it. + + The nine --board-* properties in style.css already resolve to the page's + own tokens, so a theme that sets none of them is the palette the board had + before the store existed. Restating them here would be a second copy of + nine colours that the light/dark blocks in style.css already own, and a + second copy is the copy that goes stale — which is the rule the rest of + this repository is built on. + + So: the selector, empty, as the shape every other theme follows. Delete the + rule and nothing changes; it is here so that reading this folder tells you + what a theme is. + + What a theme may set is listed in style.css under "the board's palette". */ + +:root[data-hud-theme="chapter"] { + /* the page's own colours, unchanged */ +} diff --git a/lab/control/ui/hud/themes/chapter/theme.json b/lab/control/ui/hud/themes/chapter/theme.json new file mode 100644 index 0000000..c5195b2 --- /dev/null +++ b/lab/control/ui/hud/themes/chapter/theme.json @@ -0,0 +1,7 @@ +{ + "id": "chapter", + "name": "Chapter", + "note": "The guide's own palette. The board borrows the page's colours and overrides none of them — this is what it looked like before there was a store.", + "ground": "any", + "author": "the guide" +} diff --git a/lab/control/ui/hud/themes/warroom/theme.css b/lab/control/ui/hud/themes/warroom/theme.css new file mode 100644 index 0000000..ee75ba8 --- /dev/null +++ b/lab/control/ui/hud/themes/warroom/theme.css @@ -0,0 +1,27 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + Copyright (C) 2026 Hashem Aldhaheri */ + +/* Warroom — the board as an operations wall. + + Drawn for a dark ground; theme.json says so, and selecting it moves the page + there. Amber carries the accent, which leaves green and red to mean only one + thing each: a rung that held, and a rung that did not. On the Chapter palette + the accent is teal and "ok" is green, which is two greens, and at a glance + across a room they are one. + + The nine properties below are the whole surface a theme has. They are listed + in style.css under "the board's palette"; setting anything else here would + reach past the board and repaint the panels, which is not what a HUD theme + is for. */ + +:root[data-hud-theme="warroom"] { + --board-accent: #e8a33d; /* the tailnet, the gates, anything the lab owns */ + --board-danger: #e2564a; /* a rule that refused, and the attacker */ + --board-warn: #d98b2b; + --board-ok: #5bbf7d; /* a rung that held — the only green on the board */ + --board-line: #3b4652; /* gate posts, the ground grid, rails */ + --board-text: #edf1f5; + --board-muted: #9aa8b6; + --board-faint: #62707e; + --board-card: #12171c; /* the plane the machines stand on */ +} diff --git a/lab/control/ui/hud/themes/warroom/theme.json b/lab/control/ui/hud/themes/warroom/theme.json new file mode 100644 index 0000000..c6d3572 --- /dev/null +++ b/lab/control/ui/hud/themes/warroom/theme.json @@ -0,0 +1,7 @@ +{ + "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.", + "ground": "dark", + "author": "the guide" +} diff --git a/lab/control/ui/index.html b/lab/control/ui/index.html index 93b84a4..8dfc6a6 100644 --- a/lab/control/ui/index.html +++ b/lab/control/ui/index.html @@ -50,6 +50,17 @@ + + @@ -452,7 +463,8 @@

The disclaimer

diff --git a/lab/control/ui/style.css b/lab/control/ui/style.css index 30c3dba..2561967 100644 --- a/lab/control/ui/style.css +++ b/lab/control/ui/style.css @@ -40,6 +40,32 @@ --radius: 10px; } +/* ---- the board's palette ---- + These nine, and only these nine, are what readTokens() in hud/scene.js + reads and what a HUD theme under hud/themes/ may redefine. They are the + contract: a theme setting anything else reaches past the drawing and + repaints the panels around it, which is not what a board theme is for. + + Each defaults to the page token it used to read directly, so the light and + dark blocks below stay the single home of those nine colours and a theme + that overrides none of them — hud/themes/chapter — is the board exactly as + it was before the store existed. + + They sit outside the theme blocks on purpose. A var() resolves where it is + used, not where it is declared, so one definition here follows the page + into light, into dark, and into whatever a theme does on top. */ +:root { + --board-accent: var(--accent); + --board-danger: var(--danger); + --board-warn: var(--warn); + --board-ok: var(--ok); + --board-line: var(--border-strong); + --board-text: var(--text); + --board-muted: var(--text-muted); + --board-faint: var(--text-faint); + --board-card: var(--bg-raised); +} + @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg: #0e1116; --bg-raised: #161b22; --bg-sunken: #0a0d12; --bg-code: #12171f; @@ -494,6 +520,22 @@ pre.out.live { max-height: 38vh; overflow-y: auto; } } .themebtn:hover { border-color: var(--accent); color: var(--accent-text); } +/* The board's palette picker, sitting next to the page's light/dark button and + deliberately quieter than it: one is a setting most readers press once, the + other is a setting most readers never press at all. */ +.hudtheme { + display: inline-flex; align-items: center; gap: 6px; + font-size: 12.5px; color: var(--text-faint); +} +.hudtheme > span { text-transform: uppercase; letter-spacing: .06em; font-size: 10.5px; } +.hudtheme select { + font: inherit; font-size: 12.5px; padding: 3px 8px; border-radius: 999px; + border: 1px solid var(--border); background: var(--bg-sunken); + color: var(--text-muted); cursor: pointer; max-width: 11ch; +} +.hudtheme select:hover { border-color: var(--accent); color: var(--accent-text); } +@media (max-width: 620px) { .hudtheme > span { display: none; } } + /* --------------------------------------------------------------------------- The board — Five Gates