Merge commit '184985b' into claude/shell-tile-fixes
This commit is contained in:
@@ -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
|
||||
|
||||
+175
-4
@@ -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; }
|
||||
|
||||
+247
-12
@@ -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 <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) {
|
||||
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) {
|
||||
|
||||
@@ -39,6 +39,15 @@
|
||||
<div class="admin-status" id="update-status"></div>
|
||||
</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>
|
||||
<h1 class="admin-h1">Modules</h1>
|
||||
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
|
||||
|
||||
@@ -1627,6 +1627,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);
|
||||
@@ -1663,6 +1664,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() {
|
||||
|
||||
@@ -809,3 +809,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;
|
||||
}
|
||||
|
||||
@@ -546,6 +546,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
|
||||
@@ -1114,6 +1168,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.' });
|
||||
}
|
||||
|
||||
@@ -1357,6 +1427,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