Themes: five presets, chosen in admin, applied live to every dashboard
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
5d55609fbe
commit
f79e8abd92
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": "<id>" }, 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, {
|
||||
|
||||
Reference in New Issue
Block a user