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/admin/admin.css b/public/admin/admin.css index bb9816f..3421ef7 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -94,6 +94,35 @@ body { overflow: auto; } } .module-health.error { color: var(--danger); } +/* a module card's own status line ("polling timers on 10.3.11.20:1025", + "no ProPresenter host configured"): a readout, so it sits in a bordered + monospace box a shade lighter than the card; ok / connecting / error + show as the text (and border) colour, the box itself never changes */ +.module-status { + display: inline-block; + max-width: 100%; + margin-top: 8px; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: color-mix(in srgb, var(--text) 5%, var(--panel)); + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11.5px; + line-height: 1.45; + color: var(--muted); + overflow-wrap: anywhere; +} +.module-status:empty { display: none; } +.module-status.ok { color: var(--text); } +.module-status.connecting { + color: var(--warn); + border-color: color-mix(in srgb, var(--warn) 40%, var(--border)); +} +.module-status.error { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); +} + .module-mount-error { margin-top: 8px; padding: 8px 10px; @@ -121,6 +150,10 @@ body { overflow: auto; } border-radius: 9px; } .field-group-title { + display: flex; + align-items: center; + gap: 7px; + position: relative; /* the ⓘ popover hangs off this row */ font-size: 11px; font-weight: 700; text-transform: uppercase; @@ -128,8 +161,82 @@ body { overflow: auto; } color: var(--muted); margin-bottom: 10px; } +.field-group-name { min-width: 0; } .field-group .field:last-child { margin-bottom: 0; } +/* group help (configGroups[name].help): an ⓘ beside the title. Hover or + focus peeks at the text; a click pins it open until the next click. */ +.group-info { display: inline-flex; align-items: center; } +.group-info-btn { + width: 16px; + height: 16px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--muted); + border-radius: 50%; + background: transparent; + color: var(--muted); + font: italic 700 10px/1 ui-serif, Georgia, "Times New Roman", serif; + text-transform: none; + letter-spacing: 0; + cursor: help; +} +.group-info-btn:hover, +.group-info.is-open .group-info-btn { color: var(--text); border-color: var(--text); } +.group-info-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +.group-info-pop { + display: none; + position: absolute; + left: 0; + top: calc(100% + 6px); + z-index: 5; + max-width: min(440px, 100%); + padding: 8px 10px; + background: color-mix(in srgb, var(--text) 6%, var(--panel)); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + color: var(--text); + font-size: 12px; + font-weight: 400; + line-height: 1.5; + letter-spacing: normal; + text-transform: none; + white-space: normal; + cursor: auto; +} +/* keeps the popover open while the pointer crosses the gap down to it */ +.group-info-pop::before { content: ""; position: absolute; left: 0; right: 0; top: -8px; height: 8px; } +.group-info:hover .group-info-pop, +.group-info-btn:focus-visible + .group-info-pop, +.group-info.is-open .group-info-pop { display: block; } + +/* group toggle (configGroups[name].toggle): the named switch sits at the + right of the title row as the group's on/off; the body dims while it is + off but stays editable */ +.field-group-toggle { + margin-left: auto; + display: inline-flex; + align-items: center; + flex: none; +} +.field-group-toggle .field { + margin: 0; + max-width: none; + font-size: 12px; + font-weight: 600; + letter-spacing: normal; + text-transform: none; + color: var(--text); +} +.field-group-toggle .field.switch-field { flex-direction: row-reverse; gap: 8px; } +.field-group-toggle .field.switch-field .switch-label { font-size: 12px; } +.field-group-toggle .field.check { flex-direction: row-reverse; gap: 8px; } +.field-group.is-off > .field-group-body { opacity: 0.45; transition: opacity 0.15s; } +.field-group.has-toggle > .field-group-body { transition: opacity 0.15s; } + /* collapsible groups (manifest configGroups[name].collapsed) */ details.field-group > summary.field-group-title { cursor: pointer; @@ -160,18 +267,16 @@ details.field-group > summary.field-group-title:hover { color: var(--text); } gap: 4px 16px; } .field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; } -.field-group-body.is-columns .field-group-help { grid-column: 1 / -1; } @media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } } -.field-group-help, +/* one line of guidance under a control (field `help`) */ .field-help { display: block; font-size: 11.5px; line-height: 1.45; color: var(--muted); + margin-top: 2px; } -.field-group-help { margin: -4px 0 8px; } -.field-help { margin-top: 2px; } .field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } /* showWhen: a hidden dependent field must actually collapse — `.field`'s @@ -312,3 +417,69 @@ a.btn { text-decoration: none; } .catalog-title { display: flex; align-items: center; gap: 8px; } .catalog-row .module-desc { margin: 4px 0 2px; } .catalog-row .module-health { margin-top: 2px; } + +/* ── theme ──────────────────────────────────────────────────────────── */ + +/* one swatch per preset: a miniature dashboard (page, a tile on it, the + accent as a status dot) and the name; the current one wears the accent */ +.theme-swatches { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); + gap: 10px; + margin-bottom: 6px; +} +.theme-swatch { + display: flex; + flex-direction: column; + gap: 7px; + padding: 8px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + color: var(--text); + font: inherit; + font-size: 12px; + font-weight: 600; + text-align: left; + cursor: pointer; +} +.theme-swatch:hover { border-color: var(--muted); } +.theme-swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.theme-swatch.is-current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.theme-preview { + position: relative; + display: block; + height: 46px; + border-radius: 6px; + background: var(--sw-bg); + border: 1px solid color-mix(in srgb, var(--sw-panel) 60%, var(--sw-accent)); + overflow: hidden; +} +.theme-preview::before { + content: ""; + position: absolute; + left: 8px; right: 8px; top: 10px; bottom: 8px; + border-radius: 4px; + background: var(--sw-panel); + border: 1px solid color-mix(in srgb, var(--sw-panel) 75%, var(--sw-accent)); +} +.theme-preview::after { + content: ""; + position: absolute; + left: 14px; top: 16px; + width: 8px; height: 8px; + border-radius: 50%; + background: var(--sw-accent); + box-shadow: 0 0 6px var(--sw-accent); +} +.theme-name { display: flex; align-items: center; gap: 6px; } +.theme-swatch.is-current .theme-name::after { + content: "current"; + margin-left: auto; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--accent); +} +#theme-status { margin-top: 4px; } diff --git a/public/admin/admin.js b/public/admin/admin.js index f44bb81..497ebd0 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -22,6 +22,9 @@ const updateStatus = document.getElementById('update-status'); const updateBtn = document.getElementById('update-btn'); const updateChanges = document.getElementById('update-changes'); const shellVersionEl = document.getElementById('shell-version'); +const themeSection = document.getElementById('theme-section'); +const themeSwatches = document.getElementById('theme-swatches'); +const themeStatus = document.getElementById('theme-status'); /** The shell version the page loaded with — the update flow watches it change. */ let shellVersion = ''; @@ -58,6 +61,7 @@ function showLogin() { availableSection.hidden = true; layoutsSection.hidden = true; updateCard.hidden = true; + themeSection.hidden = true; passcodeInput.focus(); } @@ -277,6 +281,99 @@ function groupHasValues(fields, mod) { }); } +/* ── group title row: the ⓘ and the group toggle ────────────────────── */ + +let groupInfoSeq = 0; + +function closeGroupInfos(except) { + for (const open of document.querySelectorAll('.group-info.is-open')) { + if (open === except) continue; + open.classList.remove('is-open'); + open.querySelector('.group-info-btn')?.setAttribute('aria-expanded', 'false'); + } +} + +/** + * configGroups[name].help as an ⓘ beside the group title: click toggles a + * small popover with the text (hover and keyboard focus show it too), so a + * group can carry a paragraph without pushing its fields down the page. A + * real button, labelled, so it works from the keyboard; inside a + * it must never fold the group. + */ +function groupInfo(groupName, text) { + const wrap = document.createElement('span'); + wrap.className = 'group-info'; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'group-info-btn'; + btn.textContent = 'i'; + btn.setAttribute('aria-label', `About ${groupName}`); + btn.setAttribute('aria-expanded', 'false'); + const pop = document.createElement('span'); + pop.className = 'group-info-pop'; + pop.id = `group-info-${++groupInfoSeq}`; + pop.setAttribute('role', 'note'); + pop.textContent = text; + btn.setAttribute('aria-describedby', pop.id); + wrap.append(btn, pop); + const setOpen = (open) => { + wrap.classList.toggle('is-open', open); + btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + }; + btn.addEventListener('click', (e) => { + e.preventDefault(); // a click in a would otherwise fold the group + e.stopPropagation(); + const open = !wrap.classList.contains('is-open'); + closeGroupInfos(wrap); + setOpen(open); + }); + pop.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + wrap.addEventListener('keydown', (e) => { + // Enter / Space activate the button (a click); they must not reach a + // above as well. Escape closes and hands focus back. + if (e.key === ' ' || e.key === 'Enter') e.stopPropagation(); + if (e.key === 'Escape' && wrap.classList.contains('is-open')) { + e.stopPropagation(); + setOpen(false); + btn.focus(); + } + }); + return wrap; +} + +// A click anywhere else, or Escape, closes an open popover. +document.addEventListener('pointerdown', (e) => { + if (!(e.target instanceof Element) || !e.target.closest('.group-info')) closeGroupInfos(); +}); +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeGroupInfos(); +}); + +/** + * configGroups[name].toggle: the named switch/boolean field is the group's + * on/off. It moves into the title row, right-aligned, and the body dims + * while it is off (fields stay editable). The field itself is untouched — + * it is still read on save and still drives showWhen dependents. + */ +function mountGroupToggle(group, field, control) { + const slot = document.createElement('span'); + slot.className = 'field-group-toggle'; + slot.appendChild(field); + // Inside a , flipping the switch must not fold the group. + slot.addEventListener('click', (e) => e.stopPropagation()); + slot.addEventListener('keydown', (e) => { + if (e.key === ' ' || e.key === 'Enter') e.stopPropagation(); + }); + group.title.appendChild(slot); + group.el.classList.add('has-toggle'); + const sync = () => group.el.classList.toggle('is-off', !control.checked); + control.addEventListener('change', sync); + sync(); +} + function buildModuleCard(mod) { const card = document.createElement('section'); card.className = 'admin-card'; @@ -351,8 +448,11 @@ function buildModuleCard(mod) { desc.textContent = mod.description; card.appendChild(desc); + // The module's own words about its upstream ("polling timers on + // 10.3.11.20:1025") — a readout, so it sits in a bordered monospace box + // and the state (ok / connecting / error) is its text colour. const healthEl = document.createElement('div'); - healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); + healthEl.className = 'module-status ' + healthClass(mod); healthEl.textContent = healthText(mod); card.appendChild(healthEl); @@ -395,7 +495,9 @@ function buildModuleCard(mod) { const collapsibles = []; // { el, meta, fields } groups that fold // Fields render in schema order; a `group` starts a labeled subsection // and consecutive same-group fields share it. The manifest's optional - // `configGroups[name]` adds { collapsed, columns, help } for that group. + // `configGroups[name]` adds { collapsed, columns, help, toggle } for + // that group: help is an ⓘ beside the title, toggle names the switch + // that is the group's on/off and lives in the title row. const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {}; let groupName = null; let container = form; @@ -412,7 +514,11 @@ function buildModuleCard(mod) { group.className = 'field-group'; const title = document.createElement(folds ? 'summary' : 'div'); title.className = 'field-group-title'; - title.textContent = g; + const titleName = document.createElement('span'); + titleName.className = 'field-group-name'; + titleName.textContent = g; + title.appendChild(titleName); + if (meta.help) title.appendChild(groupInfo(g, String(meta.help))); group.appendChild(title); const body = document.createElement('div'); body.className = 'field-group-body'; @@ -421,16 +527,16 @@ function buildModuleCard(mod) { body.classList.add('is-columns'); body.style.setProperty('--cols', String(cols)); } - if (meta.help) { - const help = document.createElement('div'); - help.className = 'field-group-help'; - help.textContent = meta.help; - body.appendChild(help); - } group.appendChild(body); form.appendChild(group); container = body; - currentGroup = { el: group, meta, fields: [] }; + currentGroup = { + el: group, + title, + meta, + fields: [], + toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '', + }; if (folds) collapsibles.push(currentGroup); } else { container = form; @@ -444,7 +550,13 @@ function buildModuleCard(mod) { else if (refetch) reloaders.push(() => refetch()); if (spec?.showWhen) dependents.push({ field, when: spec.showWhen }); if (currentGroup) currentGroup.fields.push({ key, spec }); - container.appendChild(field); + if (currentGroup && control && key === currentGroup.toggleKey) { + // The group's on/off: title row, not the body (never both). A + // toggle naming a non-boolean field is ignored and renders normally. + mountGroupToggle(currentGroup, field, control); + } else { + container.appendChild(field); + } } // A field with showWhen:"K" is visible only while K's toggle is on. for (const dep of dependents) { @@ -531,7 +643,7 @@ async function refreshStatus() { if (!els) continue; els.dot.className = 'dot ' + healthClass(mod); els.healthEl.textContent = healthText(mod); - els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); + els.healthEl.className = 'module-status ' + healthClass(mod); if (!mod.incompatible) { els.mountError.hidden = !mod.mountError; els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : ''; @@ -640,10 +752,133 @@ async function loadState() { for (const mod of state.modules || []) { modulesList.appendChild(buildModuleCard(mod)); } + loadTheme(); loadLayouts(); loadCatalog(false); } +/* ── theme ──────────────────────────────────────────────────────────── */ + +/* The dashboard palette is a server-wide choice. The server lists the + presets (GET /api/theme — public, dashboards need it too) and the admin + page shows them as swatches; picking one POSTs it, applies it here at + once, and the server's `theme` shell event switches every open dashboard + — and any other admin page — live. The palettes themselves are + html[data-theme="…"] variable sets in the shell's style.css, which this + page shares, so the admin page follows its own choice. */ + +const LS_THEME = 'proddash:theme'; +let themeList = []; +let currentTheme = ''; + +function applyTheme(id) { + const theme = String(id || ''); + if (!/^[a-z][a-z0-9-]{0,31}$/.test(theme)) return; + document.documentElement.dataset.theme = theme; + try { localStorage.setItem(LS_THEME, theme); } catch { /* private mode — fine */ } +} + +// The last-known theme first (shared with the dashboard, same origin), so +// the page doesn't flash the default palette before the server answers. +try { applyTheme(localStorage.getItem(LS_THEME)); } catch { /* fine */ } + +function themeName(id) { + return themeList.find((t) => t.id === id)?.name || id; +} + +function renderThemes() { + themeSwatches.innerHTML = ''; + for (const t of themeList) { + const current = t.id === currentTheme; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'theme-swatch' + (current ? ' is-current' : ''); + btn.setAttribute('role', 'radio'); + btn.setAttribute('aria-checked', current ? 'true' : 'false'); + btn.title = current ? `${t.name} — the current theme` : `Switch every dashboard to ${t.name}`; + const preview = document.createElement('span'); + preview.className = 'theme-preview'; + preview.style.setProperty('--sw-bg', String(t.colors?.bg || '#000')); + preview.style.setProperty('--sw-panel', String(t.colors?.panel || '#222')); + preview.style.setProperty('--sw-accent', String(t.colors?.accent || '#fff')); + const name = document.createElement('span'); + name.className = 'theme-name'; + name.textContent = t.name; + btn.append(preview, name); + btn.addEventListener('click', () => chooseTheme(t.id)); + themeSwatches.appendChild(btn); + } +} + +/** + * Fetch the server's theme and list. Applied to this page either way (the + * endpoint is public, so even the login card wears the right palette); the + * swatch section itself is only revealed for an unlocked page. + */ +async function loadTheme(reveal = true) { + let body; + try { + body = await api('/api/theme'); + } catch { + if (reveal) themeSection.hidden = true; // a server without themes — nothing to offer + return; + } + themeList = Array.isArray(body.themes) ? body.themes.filter((t) => t && t.id && t.name) : []; + currentTheme = String(body.theme || ''); + applyTheme(currentTheme); + renderThemes(); + if (reveal) themeSection.hidden = !themeList.length; +} + +loadTheme(false); + +async function chooseTheme(id) { + if (id === currentTheme) return; + const previous = currentTheme; + currentTheme = id; + applyTheme(id); // at once, here — the server's event confirms it everywhere else + renderThemes(); + themeStatus.textContent = ''; + themeStatus.className = 'admin-status'; + try { + await api('/api/admin/theme', { method: 'POST', body: JSON.stringify({ theme: id }) }); + themeStatus.textContent = `${themeName(id)} — every open dashboard switched.`; + themeStatus.className = 'admin-status ok'; + } catch (e) { + currentTheme = previous; + applyTheme(previous); + renderThemes(); + themeStatus.textContent = e.message; + themeStatus.className = 'admin-status error'; + if (e.authRequired) showLogin(); + } +} + +// Follow a theme change made elsewhere (another admin page) live. +let themeEvents = null; +let themeEventsRetry = null; + +function subscribeThemeEvents() { + try { themeEvents?.close(); } catch { /* not open */ } + themeEvents = new EventSource('/api/events'); + themeEvents.addEventListener('theme', (e) => { + let theme = ''; + try { theme = String(JSON.parse(e.data).theme || ''); } catch { return; } + if (!theme || theme === currentTheme) return; + currentTheme = theme; + applyTheme(theme); + renderThemes(); + }); + themeEvents.onerror = () => { + if (themeEvents.readyState === EventSource.CLOSED) { + clearTimeout(themeEventsRetry); + themeEventsRetry = setTimeout(subscribeThemeEvents, 4000); + } + }; +} + +subscribeThemeEvents(); + /* ── the repo: available modules, module updates, shell update ──────── */ function fmtTime(ms) { diff --git a/public/admin/index.html b/public/admin/index.html index ec8864c..d11c69e 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -39,6 +39,15 @@
+ +