From a6dc2ddb8520694ef42fe95c30f3c922726fdc1f Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 13 Sep 2026 12:32:31 -0400 Subject: [PATCH 01/15] Pin the contracts the next round of work meets at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four pieces of work are about to run in parallel — the shell, the admin page, the Timers module and the PCO Plan module — and three of them meet at things that didn't exist yet. This writes those down first, so they are built once, the same way. - **Offering timers to the Timers module.** A module declares `"provides": ["timers"]` and serves `GET /timers` (a cheap snapshot with startedAt/elapsedMs/targetMs, an optional status line, optionally an SSE stream). The consumer extrapolates locally, so no fast polling. Modules meet over loopback through each other's routes, as PCO Plan already does to follow ProPresenter. - **Admin group controls.** `configGroups[name].help` becomes an ⓘ beside the group title rather than a paragraph under it; `configGroups[name].toggle` names a switch that renders in the title row as the group's on/off. - **Switches, labelled with nouns.** On means shown or enabled; the label shouldn't say so twice. - **Themes** swap the shell variables; a tile's own configured colour is set closer and wins. Two shell hooks make it real: `/api/modules` forwards `provides`, and module servers get `shell.port` in init/routes/tiles instead of guessing it from proddash.json. Clock: descriptions gone, "Show seconds" is "Seconds" (1.0.1). Co-Authored-By: Claude Fable 5.1 --- docs/MODULE-GUIDE.md | 98 ++++++++++++++++++++++++++++++++++++--- modules/clock/module.json | 7 ++- server.js | 11 +++-- 3 files changed, 103 insertions(+), 13 deletions(-) diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 64ca0c2..699bfab 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -92,6 +92,7 @@ the app); its settings are kept for a later reinstall. | `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. | | `description` | no | Shown in the admin page / picker. | | `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. | +| `provides` | no | Capabilities other modules can consume — `["timers"]` today. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. | | `client` | yes | Client entry file, loaded as an ES module. | | `server` | no | Server entry file (CommonJS), loaded with `require()`. | | `style` | no | A stylesheet the shell injects once per module. | @@ -115,8 +116,13 @@ Both schemas map field names to specs: } ``` -`boolean` renders as a checkbox; `switch` is the same true/false value shown -as a toggle (use it for a feature on/off that other fields depend on). +`boolean` and `switch` are the same true/false value. In a tile's gear popover +both render as a switch; in the admin page `switch` is the toggle and +`boolean` the plainer checkbox (use `switch` for a feature on/off that other +fields depend on). **Label a switch with the noun of what it turns on** — +"Seconds", "Top bar", "Section headers" — never "Show seconds": on means +shown or enabled, off means hidden or disabled, and the label shouldn't say +so twice. `color` renders a swatch picker and stores a `#rrggbb` string (give it a hex default; the tile applies it as a CSS custom property — see `pco-plan`). @@ -141,7 +147,15 @@ the group name: still empty. Omit it and the group is a plain, always-open block. - `columns`: 2–4 lays the group's fields out in a grid — the way to keep a long checklist of booleans compact. -- `help`: a line of guidance at the top of the group. +- `help`: guidance for the whole group. The admin page shows it as an + ⓘ beside the group title (click or hover to read), so a group can carry a + paragraph without pushing its fields down the page. Say where a value comes + from, not what a field is. +- `toggle`: the key of a `switch` field in this group. It renders in the + group's title row, at the right, as the group's on/off; the rest of the group + dims while it is off, and the field is not repeated in the body. This is how + an integration is disabled without unmounting the module — ProPresenter + timers, say, on a Sunday when ProPresenter isn't running. `help` on a field puts one line of guidance under its control — allowed placeholders, where a value comes from. Keep labels short and put the @@ -365,7 +379,11 @@ own in `stop()` anyway. palette: `--bg` (page), `--panel` (tile), `--border`, `--text`, `--muted`, `--accent`, `--danger`, `--warn`. Derive tints with `color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes - every module look native on the dark, high-contrast booth theme. + every module look native — and what makes the dashboard's **themes** + (Admin → Theme) reach into every tile: a theme swaps those variables and + nothing else. A colour a tile is configured with (a `color` instance + setting, applied as a custom property on the tile) is set closer than the + theme and wins over it, by design. 3. **No external network calls from the client.** Browsers talk only to ProdDash. Anything upstream (your device, service, API) goes through your @@ -385,7 +403,7 @@ ethos; Node 18+ so global `fetch` is available). Exports either or both of: ```js module.exports = { - init({ config, log }) { + init({ config, log, shell }) { // Called on startup / re-init, BEFORE routes(). Start your upstream // connection here. Return a handle: return { @@ -394,7 +412,7 @@ module.exports = { }; }, - routes({ config, log }) { + routes({ config, log, shell }) { // Route table, mounted under /api/modules// return { 'GET /state': (req, res) => { … }, @@ -410,6 +428,9 @@ module.exports = { - `config` is your admin config: `configSchema` defaults overlaid with what the admin page saved — **including password fields**. - `log(...)` prefixes output with your module id. +- `shell.port` is the port ProdDash is serving on — what you need to read + another module's routes over loopback (`http://127.0.0.1:${shell.port}/api/ + modules//…`). Never guess it. - Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is the matched remainder (leading `/` included) and `req.search` is the query string (with `?`, or `''`) — so a proxy rebuilds the upstream path as @@ -496,6 +517,71 @@ per-client `res.write` in try/catch (drop the client on failure). The `propresenter-now-next` module is the reference implementation of this pattern. +## Working with other modules + +Modules don't import each other. When one needs what another knows, it reads +that module's **own server routes over loopback**, exactly as a browser would: +`http://127.0.0.1:${shell.port}/api/modules//…`. The PCO Plan module +follows ProPresenter this way — it reads `propresenter-now-next`'s `/stream` +rather than talking to ProPresenter a second time. A consumer must survive the +provider being disabled, uninstalled, or restarting mid-service (404s and +dropped connections are normal; retry with backoff and say so in `health()`). + +### Offering timers to the Timers module + +The Timers module shows timers from anywhere: ProPresenter, LTC, its own +clock, and any module that offers some. A module offers timers by: + +1. Declaring it — `"provides": ["timers"]` in `module.json`. The shell + forwards `provides` in `/api/modules`, which is how the Timers module finds + providers without a list anyone maintains. +2. Serving `GET /timers` from its server routes, returning a snapshot: + +```json +{ + "source": "pco-plan", + "label": "PCO Plan", + "updatedAt": 1757800000000, + "timers": [ + { + "id": "service", + "label": "Service", + "kind": "elapsed", + "state": "running", + "startedAt": 1757799000000, + "elapsedMs": 1000000, + "remainingMs": null, + "targetMs": 4500000, + "status": { "text": "Welcome & Announcements", "tone": "ok" }, + "detail": "Item 3 of 12" + } + ] +} +``` + +| Field | Meaning | +| --- | --- | +| `source`, `label` | Your module id, and the heading the Timers module puts over your timers. | +| `updatedAt` | ms since epoch when this snapshot was taken. Every duration below is *as of this instant*. | +| `id` | Stable within your module — the Timers module keys tile settings (shown/hidden, colours) on `source` + `id`. | +| `kind` | `elapsed` (counts up), `countdown` (counts down to zero), `clock` (a time of day; put the ISO time in `status.text`). | +| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). | +| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. | +| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. | +| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. | +| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. | +| `detail` | Optional second line. | + +The consumer derives the displayed time locally from `startedAt`/`elapsedMs` +and its own clock, so a snapshot every couple of seconds is plenty — `/timers` +must be cheap to answer (serve from state you already hold; never call +upstream to answer it). Optionally also serve `GET /timers/stream` as SSE +(`event: timers`, `data:` the same object, on every change plus a heartbeat +comment every 15 s); the consumer uses the stream when it exists and polls +`/timers` otherwise. Only offer timers a production tech would want on a +wall — the Timers module lets them hide any of them per tile, but every entry +you add is a decision someone has to make. + ## Complete minimal example A working clock — copy-paste this as your starter. (A slightly fancier diff --git a/modules/clock/module.json b/modules/clock/module.json index f95087f..d90051c 100644 --- a/modules/clock/module.json +++ b/modules/clock/module.json @@ -1,9 +1,8 @@ { "id": "clock", "name": "Clock", - "version": "1.0.0", + "version": "1.0.1", "proddash": ">=1.1.0", - "description": "Big booth clock — the smallest possible ProdDash module", "client": "client.js", "style": "style.css", "minSize": { "w": 2, "h": 1 }, @@ -18,7 +17,7 @@ { "value": "12h", "label": "12-hour" } ] }, - "showSeconds": { "type": "boolean", "label": "Show seconds", "default": true }, - "showDate": { "type": "boolean", "label": "Show date", "default": false } + "showSeconds": { "type": "boolean", "label": "Seconds", "default": true }, + "showDate": { "type": "boolean", "label": "Date", "default": false } } } diff --git a/server.js b/server.js index 5655907..2bddb72 100644 --- a/server.js +++ b/server.js @@ -373,14 +373,17 @@ function mountModule(id) { // Re-require fresh config on every (re)mount; the module code itself stays cached. const mod = require(serverPath); const config = effectiveConfig(id); + // What a module needs to reach the rest of ProdDash — another module's + // routes over loopback, chiefly — without guessing. + const shell = { port: PORT }; if (typeof mod.init === 'function') { - entry.handle = mod.init({ config, log }) || null; + entry.handle = mod.init({ config, log, shell }) || null; } if (typeof mod.routes === 'function') { - entry.routes = parseRouteTable(mod.routes({ config, log })); + entry.routes = parseRouteTable(mod.routes({ config, log, shell })); } if (typeof mod.tiles === 'function') { - entry.tiles = () => mod.tiles({ config, log }); + entry.tiles = () => mod.tiles({ config, log, shell }); } log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : '')); } catch (err) { @@ -510,6 +513,8 @@ async function clientManifest(id) { instanceSchema: man.instanceSchema || {}, instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {}, hasServer: Boolean(man.server), + // capabilities other modules consume ("timers") — see the module guide + provides: Array.isArray(man.provides) ? man.provides.map(String) : [], config: clientConfig(id), tiles: await moduleTiles(id), }; From f79e8abd92f26ecf82126ef85e40d617481f744d Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 13 Sep 2026 12:55:36 -0400 Subject: [PATCH 02/15] Themes: five presets, chosen in admin, applied live to every dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: one THEMES list; `theme` is read from the data directory's proddash.json like port/adminPasscode; GET /api/theme is public (dashboards need it before anyone logs in) and returns { theme, themes: [{ id, name, colors: { bg, panel, accent } }] }; POST /api/admin/theme { theme } validates against the list, writes the key into proddash.json keeping every other key, updates the in-memory value and broadcasts a `theme` shell event over /api/events. style.css: four html[data-theme] variable sets appended at the end — Harbor (navy/cyan), Graphite (charcoal/blue), Ember (warm black/orange) and Daylight (light, for daytime FOH) — each swapping exactly the eight palette variables; :root stays Booth, the default. shell.js: a self-contained theme section — the last-known theme (localStorage proddash:theme) goes on synchronously, then the server's answer, then live changes via the SSE event; one listener line added in subscribeEvents. Tile-configured colours are set on the tile and still win. README: a Themes subsection under Configure and the `theme` row in the proddash.json key table. Co-Authored-By: Claude Fable 5.1 --- README.md | 17 +++++++++++ public/shell.js | 38 ++++++++++++++++++++++++ public/style.css | 60 ++++++++++++++++++++++++++++++++++++++ server.js | 75 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+) diff --git a/README.md b/README.md index 80696b3..a56fa13 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ with this machine's shell settings — the same keys as the checked-in | --- | --- | | `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. | | `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. | +| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). | Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and `PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path @@ -111,6 +112,22 @@ so and falls back to the app's `config/` folder. directory, so nothing has to be re-entered. The old files are left in place and ignored from then on. +### Themes + +The whole dashboard — every tile, on every browser — draws with one palette, +chosen in the admin page's **Theme** section and switched live: pick a swatch +and open dashboards change without a reload. Five presets ship: **Booth** +(the default dark, mint accent), **Harbor** (deep navy, cyan), **Graphite** +(neutral charcoal, electric blue), **Ember** (warm broadcast black, orange) +and **Daylight**, a light palette for a front-of-house desk in daytime. Each +keeps the status colours apart — the accent is never near the danger red or +the warning amber — and muted text readable in a dark booth. The choice is +stored as `theme` in the data directory's `proddash.json` (table above), so +it holds across restarts and updates; a browser remembers the last theme it +saw and applies it before the server answers, so nothing flashes. Modules +style with the shell's colour variables, which is why they follow; a colour +set on a tile itself (a PCO Plan header colour, say) is deliberately kept. + ## Installing modules and updating Everything below happens in the **admin page**; ProdDash talks to its GitHub diff --git a/public/shell.js b/public/shell.js index 5a8fc98..454dc4a 100644 --- a/public/shell.js +++ b/public/shell.js @@ -1381,6 +1381,7 @@ function subscribeEvents() { try { eventsSource?.close(); } catch { /* not open */ } eventsSource = new EventSource('/api/events'); eventsSource.addEventListener('modules-changed', () => handleModulesChanged()); + eventsSource.addEventListener('theme', handleThemeEvent); eventsSource.onerror = () => { if (eventsSource.readyState === EventSource.CLOSED) { clearTimeout(eventsRetry); @@ -1417,6 +1418,43 @@ async function handleModulesChanged() { } } +/* ── theme ──────────────────────────────────────────────────────────── */ + +/* The dashboard's palette is a server-wide choice (Admin → Theme). Five + preset variable sets live in style.css as html[data-theme="…"]; applying + one is setting that attribute. The last-known theme goes on synchronously + from localStorage so a reload never flashes the default palette, then the + server's answer (GET /api/theme) wins, and a `theme` shell event on + /api/events switches every open dashboard the moment an admin picks + another. Modules style with the shell variables, so they follow; a colour + a tile is configured with is set on the tile itself and stays as chosen. */ + +const LS_THEME = 'proddash:theme'; + +function applyTheme(id) { + const theme = String(id || ''); + if (!/^[a-z][a-z0-9-]{0,31}$/.test(theme)) return; + document.documentElement.dataset.theme = theme; + try { localStorage.setItem(LS_THEME, theme); } catch { /* private mode — fine */ } +} + +/** `theme` shell event from /api/events (wired in subscribeEvents). */ +function handleThemeEvent(e) { + try { + applyTheme(JSON.parse(e.data).theme); + } catch { /* a malformed frame changes nothing */ } +} + +function initTheme() { + try { applyTheme(localStorage.getItem(LS_THEME)); } catch { /* fine */ } + fetch('/api/theme', { cache: 'no-store' }) + .then((res) => (res.ok ? res.json() : null)) + .then((body) => { if (body?.theme) applyTheme(body.theme); }) + .catch(() => { /* offline: the remembered theme stands */ }); +} + +initTheme(); + /* ── boot ───────────────────────────────────────────────────────────── */ async function boot() { diff --git a/public/style.css b/public/style.css index 2ff8649..e8c03a7 100644 --- a/public/style.css +++ b/public/style.css @@ -755,3 +755,63 @@ body.fullscreen #grid { padding-bottom: 0; } @media (max-width: 560px) { #topbar { padding: 0 8px; gap: 6px; } } + +/* ── themes ────────────────────────────────────────────────────────── */ + +/* Preset palettes, chosen in Admin → Theme and applied by shell.js (and the + admin page) as html[data-theme="…"]. Each swaps exactly the eight palette + variables the module guide names — nothing else — so every module and + every shell element follows, and a colour a tile is configured with (set + on the tile itself) still wins. The :root block at the top of this file is + "booth", the default; the list of ids and names lives in server.js + (GET /api/theme). All four are booth-grade: high contrast, the accent + nowhere near the danger red or the warning amber, muted still readable. */ + +/* Harbor — deep navy, cyan accent */ +html[data-theme="harbor"] { + --bg: #06101f; + --panel: #0c1a30; + --border: #1a2b48; + --text: #e6eefb; + --muted: #8ea4c8; + --accent: #2ad4ee; + --danger: #ff5a5f; + --warn: #f7b53b; +} + +/* Graphite — neutral charcoal, electric-blue accent */ +html[data-theme="graphite"] { + --bg: #0f1012; + --panel: #17191d; + --border: #2a2d33; + --text: #f0f2f4; + --muted: #9ca3ad; + --accent: #6cb4ff; + --danger: #ff4d4f; + --warn: #ffb224; +} + +/* Ember — warm broadcast black, orange accent (danger stays a cool red, + warn a pale yellow, so the three status colours never blur) */ +html[data-theme="ember"] { + --bg: #120c0a; + --panel: #1d1411; + --border: #35251e; + --text: #f6ede6; + --muted: #b39b8c; + --accent: #ff8f3a; + --danger: #f03247; + --warn: #ffd86b; +} + +/* Daylight — the light one, for a front-of-house desk in daytime */ +html[data-theme="daylight"] { + --bg: #eef1f5; + --panel: #ffffff; + --border: #cfd6e0; + --text: #14181f; + --muted: #5b6675; + --accent: #0b8a5f; + --danger: #d1242f; + --warn: #b26a00; +} diff --git a/server.js b/server.js index 5655907..5652f06 100644 --- a/server.js +++ b/server.js @@ -541,6 +541,60 @@ setInterval(() => { } }, 25000).unref(); +/* ── themes ─────────────────────────────────────────────────────────── */ + +/* Preset palettes for the whole dashboard, chosen in Admin → Theme. The + variable sets themselves live in public/style.css (html[data-theme="…"]); + this list is the one authority on ids and names, and carries the three + colours the admin page previews a theme with. The choice is a shell + setting — `theme` in the data directory's proddash.json — and reaches open + dashboards live as a `theme` shell event. */ +const THEMES = [ + { id: 'booth', name: 'Booth', colors: { bg: '#0b0e12', panel: '#131920', accent: '#2ee59a' } }, + { id: 'harbor', name: 'Harbor', colors: { bg: '#06101f', panel: '#0c1a30', accent: '#2ad4ee' } }, + { id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } }, + { id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } }, + { id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } }, +]; +const DEFAULT_THEME = THEMES[0].id; + +function themeExists(id) { + return THEMES.some((t) => t.id === id); +} + +let currentTheme = themeExists(shellConfig.theme) ? shellConfig.theme : DEFAULT_THEME; + +/** What GET /api/theme returns — public, since dashboards need it before anyone logs in. */ +function themeView() { + return { + theme: currentTheme, + themes: THEMES.map((t) => ({ id: t.id, name: t.name, colors: { ...t.colors } })), + }; +} + +/** + * Persist the theme into this machine's proddash.json (every other key there + * — port, adminPasscode, repo… — is kept), remember it, and tell every open + * dashboard and admin page. Throws an httpError for an unknown id or an + * unreadable settings file; the route turns that into the response. + */ +function setTheme(id) { + if (!themeExists(id)) throw httpError(400, `Unknown theme "${id}".`); + const file = path.join(DATA_DIR, 'proddash.json'); + let existing = {}; + if (fs.existsSync(file)) { + existing = readJson(file, null); + if (!existing || typeof existing !== 'object' || Array.isArray(existing)) { + throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the theme.`); + } + } + writeJson(file, { ...existing, theme: id }); + currentTheme = id; + shellConfig.theme = id; + broadcastShellEvent('theme', { theme: id }); + return themeView(); +} + /* ── the repo: module catalog and shell updates ──────────────────────── */ /* The admin page installs modules from, and updates the shell against, the @@ -1109,6 +1163,22 @@ async function handleAdminApi(req, res, urlPath) { return sendJson(res, 200, { ok: true, module: adminModuleView(id) }); } + /* — theme: { "theme": "" }, validated against THEMES — */ + if (urlPath === '/api/admin/theme' && req.method === 'POST') { + if (refuseAdminWrite(req, res)) return; + let body = {}; + try { + body = JSON.parse((await readBody(req)) || '{}'); + } catch { + return sendJson(res, 400, { error: 'Malformed request.' }); + } + try { + return sendJson(res, 200, { ok: true, ...setTheme(String(body.theme ?? '')) }); + } catch (err) { + return sendJson(res, err.status || 500, { error: err.message || String(err) }); + } + } + sendJson(res, 404, { error: 'Unknown admin endpoint.' }); } @@ -1352,6 +1422,11 @@ function handleRequest(req, res) { return; } + /* — theme: public (dashboards need it without a passcode); changed via POST /api/admin/theme — */ + if (urlPath === '/api/theme' && req.method === 'GET') { + return sendJson(res, 200, themeView()); + } + /* — shell events: open dashboards learn about admin changes live — */ if (urlPath === '/api/events' && req.method === 'GET') { res.writeHead(200, { From 4dde529162a83de96f7f246121a5865e7ed25475 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 13 Sep 2026 12:55:55 -0400 Subject: [PATCH 03/15] =?UTF-8?q?Admin:=20group=20=E2=93=98,=20group=20tog?= =?UTF-8?q?gles=20in=20the=20title=20row,=20status=20readouts,=20Theme=20s?= =?UTF-8?q?ection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit configGroups[name].help now renders as an ⓘ beside the group title (.group-info / .group-info-btn / .group-info-pop): a real labelled button — click pins a popover with the text, hover and focus peek at it, Escape or a click elsewhere closes it, and inside a collapsed group's it never folds the group. The paragraph above the fields is gone; per-field help stays one small line under its control. configGroups[name].toggle names a switch/boolean field of the group: it moves into the title row, right-aligned (.field-group-toggle), as the group's on/off. While off the body dims to 0.45 (.field-group.is-off) but every field stays editable; the field is not repeated in the body, is saved exactly as before and still drives showWhen. Works with collapsed (the switch never folds the row) and columns. A toggle naming a non-boolean field is ignored. The module card's status line is a .module-status readout: 1px --border, a background a shade lighter than the card, monospace, small radius and padding, with ok / connecting / error as the text (and border) colour. New Theme section near the top: five swatches (.theme-swatch with a miniature bg/panel/accent preview), the current one marked, click POSTs /api/admin/theme and applies at once, errors show on a status line; other admin pages' changes arrive live over /api/events, and the login card already wears the server's theme. Co-Authored-By: Claude Fable 5.1 --- public/admin/admin.css | 179 ++++++++++++++++++++++++++- public/admin/admin.js | 259 ++++++++++++++++++++++++++++++++++++++-- public/admin/index.html | 9 ++ 3 files changed, 431 insertions(+), 16 deletions(-) diff --git a/public/admin/admin.css b/public/admin/admin.css index bb9816f..3421ef7 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -94,6 +94,35 @@ body { overflow: auto; } } .module-health.error { color: var(--danger); } +/* a module card's own status line ("polling timers on 10.3.11.20:1025", + "no ProPresenter host configured"): a readout, so it sits in a bordered + monospace box a shade lighter than the card; ok / connecting / error + show as the text (and border) colour, the box itself never changes */ +.module-status { + display: inline-block; + max-width: 100%; + margin-top: 8px; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: color-mix(in srgb, var(--text) 5%, var(--panel)); + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11.5px; + line-height: 1.45; + color: var(--muted); + overflow-wrap: anywhere; +} +.module-status:empty { display: none; } +.module-status.ok { color: var(--text); } +.module-status.connecting { + color: var(--warn); + border-color: color-mix(in srgb, var(--warn) 40%, var(--border)); +} +.module-status.error { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); +} + .module-mount-error { margin-top: 8px; padding: 8px 10px; @@ -121,6 +150,10 @@ body { overflow: auto; } border-radius: 9px; } .field-group-title { + display: flex; + align-items: center; + gap: 7px; + position: relative; /* the ⓘ popover hangs off this row */ font-size: 11px; font-weight: 700; text-transform: uppercase; @@ -128,8 +161,82 @@ body { overflow: auto; } color: var(--muted); margin-bottom: 10px; } +.field-group-name { min-width: 0; } .field-group .field:last-child { margin-bottom: 0; } +/* group help (configGroups[name].help): an ⓘ beside the title. Hover or + focus peeks at the text; a click pins it open until the next click. */ +.group-info { display: inline-flex; align-items: center; } +.group-info-btn { + width: 16px; + height: 16px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--muted); + border-radius: 50%; + background: transparent; + color: var(--muted); + font: italic 700 10px/1 ui-serif, Georgia, "Times New Roman", serif; + text-transform: none; + letter-spacing: 0; + cursor: help; +} +.group-info-btn:hover, +.group-info.is-open .group-info-btn { color: var(--text); border-color: var(--text); } +.group-info-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +.group-info-pop { + display: none; + position: absolute; + left: 0; + top: calc(100% + 6px); + z-index: 5; + max-width: min(440px, 100%); + padding: 8px 10px; + background: color-mix(in srgb, var(--text) 6%, var(--panel)); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + color: var(--text); + font-size: 12px; + font-weight: 400; + line-height: 1.5; + letter-spacing: normal; + text-transform: none; + white-space: normal; + cursor: auto; +} +/* keeps the popover open while the pointer crosses the gap down to it */ +.group-info-pop::before { content: ""; position: absolute; left: 0; right: 0; top: -8px; height: 8px; } +.group-info:hover .group-info-pop, +.group-info-btn:focus-visible + .group-info-pop, +.group-info.is-open .group-info-pop { display: block; } + +/* group toggle (configGroups[name].toggle): the named switch sits at the + right of the title row as the group's on/off; the body dims while it is + off but stays editable */ +.field-group-toggle { + margin-left: auto; + display: inline-flex; + align-items: center; + flex: none; +} +.field-group-toggle .field { + margin: 0; + max-width: none; + font-size: 12px; + font-weight: 600; + letter-spacing: normal; + text-transform: none; + color: var(--text); +} +.field-group-toggle .field.switch-field { flex-direction: row-reverse; gap: 8px; } +.field-group-toggle .field.switch-field .switch-label { font-size: 12px; } +.field-group-toggle .field.check { flex-direction: row-reverse; gap: 8px; } +.field-group.is-off > .field-group-body { opacity: 0.45; transition: opacity 0.15s; } +.field-group.has-toggle > .field-group-body { transition: opacity 0.15s; } + /* collapsible groups (manifest configGroups[name].collapsed) */ details.field-group > summary.field-group-title { cursor: pointer; @@ -160,18 +267,16 @@ details.field-group > summary.field-group-title:hover { color: var(--text); } gap: 4px 16px; } .field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; } -.field-group-body.is-columns .field-group-help { grid-column: 1 / -1; } @media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } } -.field-group-help, +/* one line of guidance under a control (field `help`) */ .field-help { display: block; font-size: 11.5px; line-height: 1.45; color: var(--muted); + margin-top: 2px; } -.field-group-help { margin: -4px 0 8px; } -.field-help { margin-top: 2px; } .field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } /* showWhen: a hidden dependent field must actually collapse — `.field`'s @@ -312,3 +417,69 @@ a.btn { text-decoration: none; } .catalog-title { display: flex; align-items: center; gap: 8px; } .catalog-row .module-desc { margin: 4px 0 2px; } .catalog-row .module-health { margin-top: 2px; } + +/* ── theme ──────────────────────────────────────────────────────────── */ + +/* one swatch per preset: a miniature dashboard (page, a tile on it, the + accent as a status dot) and the name; the current one wears the accent */ +.theme-swatches { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); + gap: 10px; + margin-bottom: 6px; +} +.theme-swatch { + display: flex; + flex-direction: column; + gap: 7px; + padding: 8px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + color: var(--text); + font: inherit; + font-size: 12px; + font-weight: 600; + text-align: left; + cursor: pointer; +} +.theme-swatch:hover { border-color: var(--muted); } +.theme-swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.theme-swatch.is-current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.theme-preview { + position: relative; + display: block; + height: 46px; + border-radius: 6px; + background: var(--sw-bg); + border: 1px solid color-mix(in srgb, var(--sw-panel) 60%, var(--sw-accent)); + overflow: hidden; +} +.theme-preview::before { + content: ""; + position: absolute; + left: 8px; right: 8px; top: 10px; bottom: 8px; + border-radius: 4px; + background: var(--sw-panel); + border: 1px solid color-mix(in srgb, var(--sw-panel) 75%, var(--sw-accent)); +} +.theme-preview::after { + content: ""; + position: absolute; + left: 14px; top: 16px; + width: 8px; height: 8px; + border-radius: 50%; + background: var(--sw-accent); + box-shadow: 0 0 6px var(--sw-accent); +} +.theme-name { display: flex; align-items: center; gap: 6px; } +.theme-swatch.is-current .theme-name::after { + content: "current"; + margin-left: auto; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--accent); +} +#theme-status { margin-top: 4px; } diff --git a/public/admin/admin.js b/public/admin/admin.js index f44bb81..497ebd0 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -22,6 +22,9 @@ const updateStatus = document.getElementById('update-status'); const updateBtn = document.getElementById('update-btn'); const updateChanges = document.getElementById('update-changes'); const shellVersionEl = document.getElementById('shell-version'); +const themeSection = document.getElementById('theme-section'); +const themeSwatches = document.getElementById('theme-swatches'); +const themeStatus = document.getElementById('theme-status'); /** The shell version the page loaded with — the update flow watches it change. */ let shellVersion = ''; @@ -58,6 +61,7 @@ function showLogin() { availableSection.hidden = true; layoutsSection.hidden = true; updateCard.hidden = true; + themeSection.hidden = true; passcodeInput.focus(); } @@ -277,6 +281,99 @@ function groupHasValues(fields, mod) { }); } +/* ── group title row: the ⓘ and the group toggle ────────────────────── */ + +let groupInfoSeq = 0; + +function closeGroupInfos(except) { + for (const open of document.querySelectorAll('.group-info.is-open')) { + if (open === except) continue; + open.classList.remove('is-open'); + open.querySelector('.group-info-btn')?.setAttribute('aria-expanded', 'false'); + } +} + +/** + * configGroups[name].help as an ⓘ beside the group title: click toggles a + * small popover with the text (hover and keyboard focus show it too), so a + * group can carry a paragraph without pushing its fields down the page. A + * real button, labelled, so it works from the keyboard; inside a + * it must never fold the group. + */ +function groupInfo(groupName, text) { + const wrap = document.createElement('span'); + wrap.className = 'group-info'; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'group-info-btn'; + btn.textContent = 'i'; + btn.setAttribute('aria-label', `About ${groupName}`); + btn.setAttribute('aria-expanded', 'false'); + const pop = document.createElement('span'); + pop.className = 'group-info-pop'; + pop.id = `group-info-${++groupInfoSeq}`; + pop.setAttribute('role', 'note'); + pop.textContent = text; + btn.setAttribute('aria-describedby', pop.id); + wrap.append(btn, pop); + const setOpen = (open) => { + wrap.classList.toggle('is-open', open); + btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + }; + btn.addEventListener('click', (e) => { + e.preventDefault(); // a click in a would otherwise fold the group + e.stopPropagation(); + const open = !wrap.classList.contains('is-open'); + closeGroupInfos(wrap); + setOpen(open); + }); + pop.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + wrap.addEventListener('keydown', (e) => { + // Enter / Space activate the button (a click); they must not reach a + // above as well. Escape closes and hands focus back. + if (e.key === ' ' || e.key === 'Enter') e.stopPropagation(); + if (e.key === 'Escape' && wrap.classList.contains('is-open')) { + e.stopPropagation(); + setOpen(false); + btn.focus(); + } + }); + return wrap; +} + +// A click anywhere else, or Escape, closes an open popover. +document.addEventListener('pointerdown', (e) => { + if (!(e.target instanceof Element) || !e.target.closest('.group-info')) closeGroupInfos(); +}); +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeGroupInfos(); +}); + +/** + * configGroups[name].toggle: the named switch/boolean field is the group's + * on/off. It moves into the title row, right-aligned, and the body dims + * while it is off (fields stay editable). The field itself is untouched — + * it is still read on save and still drives showWhen dependents. + */ +function mountGroupToggle(group, field, control) { + const slot = document.createElement('span'); + slot.className = 'field-group-toggle'; + slot.appendChild(field); + // Inside a , flipping the switch must not fold the group. + slot.addEventListener('click', (e) => e.stopPropagation()); + slot.addEventListener('keydown', (e) => { + if (e.key === ' ' || e.key === 'Enter') e.stopPropagation(); + }); + group.title.appendChild(slot); + group.el.classList.add('has-toggle'); + const sync = () => group.el.classList.toggle('is-off', !control.checked); + control.addEventListener('change', sync); + sync(); +} + function buildModuleCard(mod) { const card = document.createElement('section'); card.className = 'admin-card'; @@ -351,8 +448,11 @@ function buildModuleCard(mod) { desc.textContent = mod.description; card.appendChild(desc); + // The module's own words about its upstream ("polling timers on + // 10.3.11.20:1025") — a readout, so it sits in a bordered monospace box + // and the state (ok / connecting / error) is its text colour. const healthEl = document.createElement('div'); - healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); + healthEl.className = 'module-status ' + healthClass(mod); healthEl.textContent = healthText(mod); card.appendChild(healthEl); @@ -395,7 +495,9 @@ function buildModuleCard(mod) { const collapsibles = []; // { el, meta, fields } groups that fold // Fields render in schema order; a `group` starts a labeled subsection // and consecutive same-group fields share it. The manifest's optional - // `configGroups[name]` adds { collapsed, columns, help } for that group. + // `configGroups[name]` adds { collapsed, columns, help, toggle } for + // that group: help is an ⓘ beside the title, toggle names the switch + // that is the group's on/off and lives in the title row. const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {}; let groupName = null; let container = form; @@ -412,7 +514,11 @@ function buildModuleCard(mod) { group.className = 'field-group'; const title = document.createElement(folds ? 'summary' : 'div'); title.className = 'field-group-title'; - title.textContent = g; + const titleName = document.createElement('span'); + titleName.className = 'field-group-name'; + titleName.textContent = g; + title.appendChild(titleName); + if (meta.help) title.appendChild(groupInfo(g, String(meta.help))); group.appendChild(title); const body = document.createElement('div'); body.className = 'field-group-body'; @@ -421,16 +527,16 @@ function buildModuleCard(mod) { body.classList.add('is-columns'); body.style.setProperty('--cols', String(cols)); } - if (meta.help) { - const help = document.createElement('div'); - help.className = 'field-group-help'; - help.textContent = meta.help; - body.appendChild(help); - } group.appendChild(body); form.appendChild(group); container = body; - currentGroup = { el: group, meta, fields: [] }; + currentGroup = { + el: group, + title, + meta, + fields: [], + toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '', + }; if (folds) collapsibles.push(currentGroup); } else { container = form; @@ -444,7 +550,13 @@ function buildModuleCard(mod) { else if (refetch) reloaders.push(() => refetch()); if (spec?.showWhen) dependents.push({ field, when: spec.showWhen }); if (currentGroup) currentGroup.fields.push({ key, spec }); - container.appendChild(field); + if (currentGroup && control && key === currentGroup.toggleKey) { + // The group's on/off: title row, not the body (never both). A + // toggle naming a non-boolean field is ignored and renders normally. + mountGroupToggle(currentGroup, field, control); + } else { + container.appendChild(field); + } } // A field with showWhen:"K" is visible only while K's toggle is on. for (const dep of dependents) { @@ -531,7 +643,7 @@ async function refreshStatus() { if (!els) continue; els.dot.className = 'dot ' + healthClass(mod); els.healthEl.textContent = healthText(mod); - els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); + els.healthEl.className = 'module-status ' + healthClass(mod); if (!mod.incompatible) { els.mountError.hidden = !mod.mountError; els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : ''; @@ -640,10 +752,133 @@ async function loadState() { for (const mod of state.modules || []) { modulesList.appendChild(buildModuleCard(mod)); } + loadTheme(); loadLayouts(); loadCatalog(false); } +/* ── theme ──────────────────────────────────────────────────────────── */ + +/* The dashboard palette is a server-wide choice. The server lists the + presets (GET /api/theme — public, dashboards need it too) and the admin + page shows them as swatches; picking one POSTs it, applies it here at + once, and the server's `theme` shell event switches every open dashboard + — and any other admin page — live. The palettes themselves are + html[data-theme="…"] variable sets in the shell's style.css, which this + page shares, so the admin page follows its own choice. */ + +const LS_THEME = 'proddash:theme'; +let themeList = []; +let currentTheme = ''; + +function applyTheme(id) { + const theme = String(id || ''); + if (!/^[a-z][a-z0-9-]{0,31}$/.test(theme)) return; + document.documentElement.dataset.theme = theme; + try { localStorage.setItem(LS_THEME, theme); } catch { /* private mode — fine */ } +} + +// The last-known theme first (shared with the dashboard, same origin), so +// the page doesn't flash the default palette before the server answers. +try { applyTheme(localStorage.getItem(LS_THEME)); } catch { /* fine */ } + +function themeName(id) { + return themeList.find((t) => t.id === id)?.name || id; +} + +function renderThemes() { + themeSwatches.innerHTML = ''; + for (const t of themeList) { + const current = t.id === currentTheme; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'theme-swatch' + (current ? ' is-current' : ''); + btn.setAttribute('role', 'radio'); + btn.setAttribute('aria-checked', current ? 'true' : 'false'); + btn.title = current ? `${t.name} — the current theme` : `Switch every dashboard to ${t.name}`; + const preview = document.createElement('span'); + preview.className = 'theme-preview'; + preview.style.setProperty('--sw-bg', String(t.colors?.bg || '#000')); + preview.style.setProperty('--sw-panel', String(t.colors?.panel || '#222')); + preview.style.setProperty('--sw-accent', String(t.colors?.accent || '#fff')); + const name = document.createElement('span'); + name.className = 'theme-name'; + name.textContent = t.name; + btn.append(preview, name); + btn.addEventListener('click', () => chooseTheme(t.id)); + themeSwatches.appendChild(btn); + } +} + +/** + * Fetch the server's theme and list. Applied to this page either way (the + * endpoint is public, so even the login card wears the right palette); the + * swatch section itself is only revealed for an unlocked page. + */ +async function loadTheme(reveal = true) { + let body; + try { + body = await api('/api/theme'); + } catch { + if (reveal) themeSection.hidden = true; // a server without themes — nothing to offer + return; + } + themeList = Array.isArray(body.themes) ? body.themes.filter((t) => t && t.id && t.name) : []; + currentTheme = String(body.theme || ''); + applyTheme(currentTheme); + renderThemes(); + if (reveal) themeSection.hidden = !themeList.length; +} + +loadTheme(false); + +async function chooseTheme(id) { + if (id === currentTheme) return; + const previous = currentTheme; + currentTheme = id; + applyTheme(id); // at once, here — the server's event confirms it everywhere else + renderThemes(); + themeStatus.textContent = ''; + themeStatus.className = 'admin-status'; + try { + await api('/api/admin/theme', { method: 'POST', body: JSON.stringify({ theme: id }) }); + themeStatus.textContent = `${themeName(id)} — every open dashboard switched.`; + themeStatus.className = 'admin-status ok'; + } catch (e) { + currentTheme = previous; + applyTheme(previous); + renderThemes(); + themeStatus.textContent = e.message; + themeStatus.className = 'admin-status error'; + if (e.authRequired) showLogin(); + } +} + +// Follow a theme change made elsewhere (another admin page) live. +let themeEvents = null; +let themeEventsRetry = null; + +function subscribeThemeEvents() { + try { themeEvents?.close(); } catch { /* not open */ } + themeEvents = new EventSource('/api/events'); + themeEvents.addEventListener('theme', (e) => { + let theme = ''; + try { theme = String(JSON.parse(e.data).theme || ''); } catch { return; } + if (!theme || theme === currentTheme) return; + currentTheme = theme; + applyTheme(theme); + renderThemes(); + }); + themeEvents.onerror = () => { + if (themeEvents.readyState === EventSource.CLOSED) { + clearTimeout(themeEventsRetry); + themeEventsRetry = setTimeout(subscribeThemeEvents, 4000); + } + }; +} + +subscribeThemeEvents(); + /* ── the repo: available modules, module updates, shell update ──────── */ function fmtTime(ms) { diff --git a/public/admin/index.html b/public/admin/index.html index ec8864c..d11c69e 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -39,6 +39,15 @@
+ +