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, {