Merge commit '184985b' into claude/timers-module-overhaul

Brings the admin page's implementation of the contracts this module builds
against: configGroups help as an info glyph, group toggles in the title row,
themes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-13 13:04:17 -04:00
co-authored by Claude Fable 5.1
7 changed files with 621 additions and 16 deletions
+17
View File
@@ -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. | | `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. | | `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 Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path `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 directory, so nothing has to be re-entered. The old files are left in place and
ignored from then on. 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 ## Installing modules and updating
Everything below happens in the **admin page**; ProdDash talks to its GitHub Everything below happens in the **admin page**; ProdDash talks to its GitHub
+175 -4
View File
@@ -94,6 +94,35 @@ body { overflow: auto; }
} }
.module-health.error { color: var(--danger); } .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 { .module-mount-error {
margin-top: 8px; margin-top: 8px;
padding: 8px 10px; padding: 8px 10px;
@@ -121,6 +150,10 @@ body { overflow: auto; }
border-radius: 9px; border-radius: 9px;
} }
.field-group-title { .field-group-title {
display: flex;
align-items: center;
gap: 7px;
position: relative; /* the ⓘ popover hangs off this row */
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
@@ -128,8 +161,82 @@ body { overflow: auto; }
color: var(--muted); color: var(--muted);
margin-bottom: 10px; margin-bottom: 10px;
} }
.field-group-name { min-width: 0; }
.field-group .field:last-child { margin-bottom: 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) */ /* collapsible groups (manifest configGroups[name].collapsed) */
details.field-group > summary.field-group-title { details.field-group > summary.field-group-title {
cursor: pointer; cursor: pointer;
@@ -160,18 +267,16 @@ details.field-group > summary.field-group-title:hover { color: var(--text); }
gap: 4px 16px; gap: 4px 16px;
} }
.field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; } .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; } } @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 { .field-help {
display: block; display: block;
font-size: 11.5px; font-size: 11.5px;
line-height: 1.45; line-height: 1.45;
color: var(--muted); 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; } .field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
/* showWhen: a hidden dependent field must actually collapse — `.field`'s /* 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-title { display: flex; align-items: center; gap: 8px; }
.catalog-row .module-desc { margin: 4px 0 2px; } .catalog-row .module-desc { margin: 4px 0 2px; }
.catalog-row .module-health { margin-top: 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; }
+247 -12
View File
@@ -22,6 +22,9 @@ const updateStatus = document.getElementById('update-status');
const updateBtn = document.getElementById('update-btn'); const updateBtn = document.getElementById('update-btn');
const updateChanges = document.getElementById('update-changes'); const updateChanges = document.getElementById('update-changes');
const shellVersionEl = document.getElementById('shell-version'); 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. */ /** The shell version the page loaded with — the update flow watches it change. */
let shellVersion = ''; let shellVersion = '';
@@ -58,6 +61,7 @@ function showLogin() {
availableSection.hidden = true; availableSection.hidden = true;
layoutsSection.hidden = true; layoutsSection.hidden = true;
updateCard.hidden = true; updateCard.hidden = true;
themeSection.hidden = true;
passcodeInput.focus(); 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 <summary>
* 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 <summary> 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
// <summary> 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 <summary>, 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) { function buildModuleCard(mod) {
const card = document.createElement('section'); const card = document.createElement('section');
card.className = 'admin-card'; card.className = 'admin-card';
@@ -351,8 +448,11 @@ function buildModuleCard(mod) {
desc.textContent = mod.description; desc.textContent = mod.description;
card.appendChild(desc); 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'); const healthEl = document.createElement('div');
healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); healthEl.className = 'module-status ' + healthClass(mod);
healthEl.textContent = healthText(mod); healthEl.textContent = healthText(mod);
card.appendChild(healthEl); card.appendChild(healthEl);
@@ -395,7 +495,9 @@ function buildModuleCard(mod) {
const collapsibles = []; // { el, meta, fields } groups that fold const collapsibles = []; // { el, meta, fields } groups that fold
// Fields render in schema order; a `group` starts a labeled subsection // Fields render in schema order; a `group` starts a labeled subsection
// and consecutive same-group fields share it. The manifest's optional // 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 : {}; const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {};
let groupName = null; let groupName = null;
let container = form; let container = form;
@@ -412,7 +514,11 @@ function buildModuleCard(mod) {
group.className = 'field-group'; group.className = 'field-group';
const title = document.createElement(folds ? 'summary' : 'div'); const title = document.createElement(folds ? 'summary' : 'div');
title.className = 'field-group-title'; 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); group.appendChild(title);
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'field-group-body'; body.className = 'field-group-body';
@@ -421,16 +527,16 @@ function buildModuleCard(mod) {
body.classList.add('is-columns'); body.classList.add('is-columns');
body.style.setProperty('--cols', String(cols)); 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); group.appendChild(body);
form.appendChild(group); form.appendChild(group);
container = body; 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); if (folds) collapsibles.push(currentGroup);
} else { } else {
container = form; container = form;
@@ -444,7 +550,13 @@ function buildModuleCard(mod) {
else if (refetch) reloaders.push(() => refetch()); else if (refetch) reloaders.push(() => refetch());
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen }); if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
if (currentGroup) currentGroup.fields.push({ key, spec }); 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. // A field with showWhen:"K" is visible only while K's toggle is on.
for (const dep of dependents) { for (const dep of dependents) {
@@ -531,7 +643,7 @@ async function refreshStatus() {
if (!els) continue; if (!els) continue;
els.dot.className = 'dot ' + healthClass(mod); els.dot.className = 'dot ' + healthClass(mod);
els.healthEl.textContent = healthText(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) { if (!mod.incompatible) {
els.mountError.hidden = !mod.mountError; els.mountError.hidden = !mod.mountError;
els.mountError.textContent = mod.mountError ? `Failed to start: ${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 || []) { for (const mod of state.modules || []) {
modulesList.appendChild(buildModuleCard(mod)); modulesList.appendChild(buildModuleCard(mod));
} }
loadTheme();
loadLayouts(); loadLayouts();
loadCatalog(false); 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 ──────── */ /* ── the repo: available modules, module updates, shell update ──────── */
function fmtTime(ms) { function fmtTime(ms) {
+9
View File
@@ -39,6 +39,15 @@
<div class="admin-status" id="update-status"></div> <div class="admin-status" id="update-status"></div>
</section> </section>
<section id="theme-section" hidden>
<h1 class="admin-h1">Theme</h1>
<p class="admin-copy">The palette every dashboard on the network uses — switched live, no reload. Modules
draw with the shell's colours, so they follow; a colour chosen on a tile itself (a PCO Plan header, say)
stays as set.</p>
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
<div class="admin-status" id="theme-status"></div>
</section>
<section id="modules-section" hidden> <section id="modules-section" hidden>
<h1 class="admin-h1">Modules</h1> <h1 class="admin-h1">Modules</h1>
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the <p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
+38
View File
@@ -1511,6 +1511,7 @@ function subscribeEvents() {
try { eventsSource?.close(); } catch { /* not open */ } try { eventsSource?.close(); } catch { /* not open */ }
eventsSource = new EventSource('/api/events'); eventsSource = new EventSource('/api/events');
eventsSource.addEventListener('modules-changed', () => handleModulesChanged()); eventsSource.addEventListener('modules-changed', () => handleModulesChanged());
eventsSource.addEventListener('theme', handleThemeEvent);
eventsSource.onerror = () => { eventsSource.onerror = () => {
if (eventsSource.readyState === EventSource.CLOSED) { if (eventsSource.readyState === EventSource.CLOSED) {
clearTimeout(eventsRetry); clearTimeout(eventsRetry);
@@ -1547,6 +1548,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 ───────────────────────────────────────────────────────────── */ /* ── boot ───────────────────────────────────────────────────────────── */
async function boot() { async function boot() {
+60
View File
@@ -756,3 +756,63 @@ body.fullscreen #grid { padding-bottom: 0; }
@media (max-width: 560px) { @media (max-width: 560px) {
#topbar { padding: 0 8px; gap: 6px; } #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;
}
+75
View File
@@ -546,6 +546,60 @@ setInterval(() => {
} }
}, 25000).unref(); }, 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 repo: module catalog and shell updates ──────────────────────── */
/* The admin page installs modules from, and updates the shell against, the /* The admin page installs modules from, and updates the shell against, the
@@ -1114,6 +1168,22 @@ async function handleAdminApi(req, res, urlPath) {
return sendJson(res, 200, { ok: true, module: adminModuleView(id) }); 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.' }); sendJson(res, 404, { error: 'Unknown admin endpoint.' });
} }
@@ -1357,6 +1427,11 @@ function handleRequest(req, res) {
return; 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 — */ /* — shell events: open dashboards learn about admin changes live — */
if (urlPath === '/api/events' && req.method === 'GET') { if (urlPath === '/api/events' && req.method === 'GET') {
res.writeHead(200, { res.writeHead(200, {