From f79e8abd92f26ecf82126ef85e40d617481f744d Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 13 Sep 2026 12:55:36 -0400 Subject: [PATCH 1/2] 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 2/2] =?UTF-8?q?Admin:=20group=20=E2=93=98,=20group=20toggl?= =?UTF-8?q?es=20in=20the=20title=20row,=20status=20readouts,=20Theme=20sec?= =?UTF-8?q?tion?= 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 @@
+ +