Files
ProdDash/public/shell.js
T

2580 lines
102 KiB
JavaScript
Raw Normal View History

/* ProdDash shell client.
Owns the tile grid: adding, dragging, resizing and removing module tiles,
plus layout persistence. The layout (tiles, positions, sizes, per-instance
settings) autosaves to this browser's localStorage — every browser is fully
independent. Modules render inside tile bodies and never touch the shell. */
/* ── grid density ─────────────────────────────────────────────────────
How many columns the board is cut into. Twelve is the default; a finer
grid lets tiles be smaller and more of them fit across (a 4K wall), a
coarser one suits a small screen read close up. It is this browser's
choice — two displays at one location may want different densities — so
it saves with the layout, local and named, and a named layout comes back
at the density it was arranged on. Row height follows the column width, so
cells keep their shape: a 3×2 tile is still a 3×2 tile, only smaller. */
const GRID_STEPS = [8, 10, 12, 16, 20, 24];
const GRID_DEFAULT = 12;
const CELL_H_DEFAULT = 72; // px per row at the default density
let COLS = GRID_DEFAULT;
const LS_LAYOUT = 'proddash:layout';
const grid = document.getElementById('grid');
const gridwrap = document.getElementById('gridwrap');
const emptyEl = document.getElementById('empty-state');
const toastEl = document.getElementById('toast');
/* ── tiny helpers ───────────────────────────────────────────────────── */
let toastTimer = null;
function toast(msg, isError = false, { action = null, ms } = {}) {
toastEl.textContent = msg;
if (action) {
// One button, one verb — "Undo" — for a change worth taking back.
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = action.label;
btn.addEventListener('click', () => {
clearTimeout(toastTimer);
toastEl.hidden = true;
action.onClick();
});
toastEl.appendChild(btn);
}
toastEl.classList.toggle('error', isError);
toastEl.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toastEl.hidden = true; }, ms ?? (action ? 8000 : 3200));
}
const autoHideBtn = document.getElementById('autohide-btn');
const modalBackdrop = document.getElementById('modal-backdrop');
const modalEl = document.getElementById('modal');
/** Minimal prompt dialog; resolves with the string or null on cancel. */
function promptModal({ title, placeholder = '', value = '', okLabel = 'Save' }) {
return new Promise((resolve) => {
modalEl.innerHTML = '';
const h = document.createElement('h2');
h.textContent = title;
const field = document.createElement('div');
field.className = 'field';
const input = document.createElement('input');
input.type = 'text';
input.placeholder = placeholder;
input.value = value;
field.appendChild(input);
const actions = document.createElement('div');
actions.className = 'actions';
const cancel = document.createElement('button');
cancel.className = 'btn';
cancel.textContent = 'Cancel';
const ok = document.createElement('button');
ok.className = 'btn primary';
ok.textContent = okLabel;
actions.append(cancel, ok);
modalEl.append(h, field, actions);
modalBackdrop.hidden = false;
input.focus();
const done = (result) => {
modalBackdrop.hidden = true;
modalEl.innerHTML = '';
resolve(result);
};
cancel.addEventListener('click', () => done(null));
ok.addEventListener('click', () => done(input.value.trim() || null));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') done(input.value.trim() || null);
if (e.key === 'Escape') done(null);
});
modalBackdrop.addEventListener('click', (e) => {
if (e.target === modalBackdrop) done(null);
}, { once: true });
});
}
/** A yes-or-no question in the same window: resolves true for the OK button. */
function confirmModal({ title, body = '', okLabel = 'OK' }) {
return new Promise((resolve) => {
modalEl.innerHTML = '';
const h = document.createElement('h2');
h.textContent = title;
const copy = document.createElement('p');
copy.className = 'modal-copy';
copy.textContent = body;
const actions = document.createElement('div');
actions.className = 'actions';
const cancel = document.createElement('button');
cancel.className = 'btn';
cancel.textContent = 'Cancel';
const ok = document.createElement('button');
ok.className = 'btn primary';
ok.textContent = okLabel;
actions.append(cancel, ok);
modalEl.append(h, copy, actions);
modalBackdrop.hidden = false;
ok.focus();
const done = (result) => {
modalBackdrop.hidden = true;
modalEl.innerHTML = '';
resolve(result);
};
cancel.addEventListener('click', () => done(false));
ok.addEventListener('click', () => done(true));
modalEl.addEventListener('keydown', (e) => { if (e.key === 'Escape') done(false); }, { once: true });
modalBackdrop.addEventListener('click', (e) => {
if (e.target === modalBackdrop) done(false);
}, { once: true });
});
}
/* ── dropdown menus ─────────────────────────────────────────────────── */
/* A menu opens the moment its button is clicked, drawn from what the page
already knows; whatever its rebuild has to fetch is slotted in when it
lands. It used to open only once the rebuild had finished, which made the
+ picker hostage to a network round trip — one that never completes when
the tiles' streams hold every connection the browser allows (see the
shared-stream pool below). rebuild(menu, stillOpen) may be async;
stillOpen() says whether the open it was started for is still current,
so a late fetch can't repaint a menu that has since closed or reopened. */
function wireDropdown(btnId, menuId, rebuild) {
const btn = document.getElementById(btnId);
const menu = document.getElementById(menuId);
let generation = 0;
btn.addEventListener('click', () => {
if (!menu.hidden) {
menu.hidden = true;
return;
}
const gen = ++generation;
const stillOpen = () => gen === generation && !menu.hidden;
menu.hidden = false;
// Whatever the rebuild does, the menu is on screen; a failure (now or
// later) leaves its previous contents rather than an invisible menu.
const failed = (err) => console.error(`[shell] ${menuId} could not be rebuilt:`, err);
try {
Promise.resolve(rebuild(menu, stillOpen)).catch(failed);
} catch (err) {
failed(err);
}
});
document.addEventListener('click', (e) => {
if (!menu.hidden && !e.target.closest('#' + btnId) && !e.target.closest('#' + menuId)) {
menu.hidden = true;
}
});
return menu;
}
function menuItem(label, onClick, { sub = '', danger = false, disabled = false, title = '' } = {}) {
const item = document.createElement('button');
item.className = 'menu-item' + (danger ? ' danger' : '');
item.disabled = disabled;
if (title) item.title = title;
const span = document.createElement('span');
span.textContent = label;
if (sub) {
const s = document.createElement('span');
s.className = 'sub';
s.textContent = sub;
span.appendChild(s);
}
item.appendChild(span);
item.addEventListener('click', onClick);
return item;
}
/* ── module registry ────────────────────────────────────────────────── */
let registry = new Map(); // module id -> manifest from GET /api/modules
/** fetch that gives up after `ms`. A request the browser has queued behind
held connections would otherwise pend forever, and so would anything
awaiting it. */
function fetchWithTimeout(url, ms, opts = {}) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), ms);
return fetch(url, { ...opts, signal: ctl.signal }).finally(() => clearTimeout(timer));
}
/** Refresh the enabled-module list. On failure the last known list is kept
(the server may just be restarting mid-service). Returns true on success. */
async function loadRegistry() {
try {
// Slow modules' tiles() are cut off server-side at 2 s, so a healthy
// answer is well inside this.
const res = await fetchWithTimeout('/api/modules', 8000);
if (!res.ok) throw new Error('modules ' + res.status);
const body = await res.json();
registry = new Map((body.modules || []).map((m) => [m.id, m]));
return true;
} catch {
return false;
}
}
/* ── layout state ───────────────────────────────────────────────────── */
/** @type {{id:string,module:string,x:number,y:number,w:number,h:number,settings:object}[]} */
let tiles = [];
const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
/* Whether tile title bars fold away until you point at them. It is part of
how a dashboard is arranged rather than a preference of this browser, so
it saves with the layout — including a named layout on the server — and a
fresh dashboard starts with the bars showing. Fullscreen folds every bar
away regardless; this is the windowed equivalent, asked for by hand. */
let autoHideHeads = false;
/* Beside the layout itself: the copy it replaced (restored if the layout
ever fails to parse) and, after loading a named layout, the arrangement
that was on screen before (what Undo goes back to). */
const LS_LAYOUT_BACKUP = LS_LAYOUT + '.backup';
const LS_LAYOUT_PREVIOUS = LS_LAYOUT + '.previous';
function saveLayout() {
try {
const next = JSON.stringify({ v: 1, autoHideHeads, gridCols: COLS, tiles });
const current = localStorage.getItem(LS_LAYOUT);
if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current);
localStorage.setItem(LS_LAYOUT, next);
} catch { /* private mode / full — layout just won't persist */ }
}
function loadLayoutFromStorage() {
for (const key of [LS_LAYOUT, LS_LAYOUT_BACKUP]) {
try {
const raw = JSON.parse(localStorage.getItem(key) || 'null');
if (raw && Array.isArray(raw.tiles)) {
// A layout that would not parse is not a reason for an empty wall.
if (key === LS_LAYOUT_BACKUP) {
toast('This browser’s layout was unreadable — the last good copy is back', true);
try { localStorage.setItem(LS_LAYOUT, JSON.stringify(raw)); } catch { /* fine */ }
}
return raw;
}
} catch { /* unreadable — try the copy behind it */ }
}
return { tiles: [] };
}
function sanitizeTile(t) {
const w = Math.max(1, Math.min(COLS, Number(t.w) || 3));
const tile = {
id: String(t.id || newId()),
module: String(t.module || ''),
x: Math.max(0, Math.min(COLS - w, Number(t.x) || 0)),
y: Math.max(0, Number(t.y) || 0),
w,
h: Math.max(1, Number(t.h) || 2),
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
2026-09-17 12:04:59 -04:00
headPinned: Boolean(t.headPinned),
// set when the tile was added from a module's tile list (multi-tile modules)
variant: t.variant ? String(t.variant) : '',
title: t.title ? String(t.title) : '',
};
if (t.minSize && Number(t.minSize.w) > 0 && Number(t.minSize.h) > 0) {
tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) };
}
return tile;
}
function newId() {
return 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
/* ── grid geometry & collision ──────────────────────────────────────── */
function normalizeGridCols(value) {
const n = Number(value);
return GRID_STEPS.includes(n) ? n : GRID_DEFAULT;
}
function cellHeightFor(cols) {
return Math.round((CELL_H_DEFAULT * GRID_DEFAULT) / cols);
}
/** Cut the board into `cols` columns. Tiles keep their grid units — the
board gets denser or coarser and everything on it changes size with it.
A tile now past the right edge is pulled back in, and one that would then
sit on another moves to the first free spot instead. */
function setGridCols(value, { save = true } = {}) {
const next = normalizeGridCols(value);
const changed = next !== COLS;
COLS = next;
grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`;
document.documentElement.style.setProperty('--cell-h', cellHeightFor(COLS) + 'px');
if (changed) {
// A coarser board may not have room for every tile where it was. Settle
// them in reading order: pull a tile past the right edge back in, and
// give one that would then land on a tile already settled the first free
// spot instead — tiles never overlap here, any more than under a drag.
const settled = [];
for (const tile of tiles.slice().sort((a, b) => a.y - b.y || a.x - b.x)) {
tile.w = Math.max(1, Math.min(COLS, tile.w));
tile.x = Math.max(0, Math.min(COLS - tile.w, tile.x));
if (settled.some((s) => overlaps(tile, s))) Object.assign(tile, spotAmong(tile.w, tile.h, settled));
settled.push(tile);
applyRect(tile);
notifyResize(tile);
}
rebuildDividers();
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
}
if (save) saveLayout();
}
const STEP_MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
const STEP_PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
/** The Layouts menu's density row — − 12 columns + — which steps without
closing the menu, so the board can be watched changing behind it. */
function gridStepper() {
const row = document.createElement('div');
row.className = 'menu-stepper';
const label = document.createElement('span');
label.className = 'menu-stepper-label';
const name = document.createElement('span');
name.textContent = 'Grid';
const sub = document.createElement('span');
sub.className = 'sub';
label.append(name, sub);
const minus = document.createElement('button');
minus.type = 'button';
minus.className = 'menu-stepper-btn';
minus.setAttribute('data-tip', 'Coarser');
minus.innerHTML = STEP_MINUS_SVG;
const value = document.createElement('span');
value.className = 'menu-stepper-value';
const plus = document.createElement('button');
plus.type = 'button';
plus.className = 'menu-stepper-btn';
plus.setAttribute('data-tip', 'Finer');
plus.innerHTML = STEP_PLUS_SVG;
const paint = () => {
const i = GRID_STEPS.indexOf(COLS);
value.textContent = `${COLS} columns`;
sub.textContent = COLS === GRID_DEFAULT ? 'default' : COLS < GRID_DEFAULT ? 'coarser' : 'finer';
minus.disabled = i <= 0;
plus.disabled = i >= GRID_STEPS.length - 1;
};
const step = (dir) => {
const i = GRID_STEPS.indexOf(COLS) + dir;
if (i < 0 || i >= GRID_STEPS.length) return;
setGridCols(GRID_STEPS[i]);
paint();
};
minus.addEventListener('click', () => step(-1));
plus.addEventListener('click', () => step(1));
paint();
row.append(label, minus, value, plus);
return row;
}
function cellMetrics() {
const style = getComputedStyle(grid);
const gapRaw = parseFloat(style.columnGap);
const gap = Number.isFinite(gapRaw) ? gapRaw : 0;
const cw = (grid.clientWidth - gap * (COLS - 1)) / COLS;
const ch = parseFloat(style.gridAutoRows) || 72;
return { cw, ch, gap };
}
function overlaps(a, b) {
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
}
function isFreeExcept(rect, excludeIds) {
if (rect.x < 0 || rect.y < 0 || rect.x + rect.w > COLS) return false;
return !tiles.some((t) => !excludeIds.includes(t.id) && overlaps(rect, t));
}
function isFree(rect, excludeId) {
return isFreeExcept(rect, [excludeId]);
}
/** First spot, scanning top-to-bottom, left-to-right, clear of these tiles. */
function spotAmong(w, h, others) {
for (let y = 0; y < 400; y += 1) {
for (let x = 0; x <= COLS - w; x += 1) {
const rect = { x, y, w, h };
if (!others.some((o) => overlaps(rect, o))) return { x, y };
}
}
return { x: 0, y: 0 };
}
/** First free spot scanning top-to-bottom, left-to-right. */
function findSpot(w, h) {
for (let y = 0; y < 200; y += 1) {
for (let x = 0; x <= COLS - w; x += 1) {
if (isFree({ x, y, w, h })) return { x, y };
}
}
return { x: 0, y: 0 };
}
function applyRect(tile) {
const entry = tileEls.get(tile.id);
if (!entry) return;
entry.el.style.gridColumn = `${tile.x + 1} / span ${tile.w}`;
entry.el.style.gridRow = `${tile.y + 1} / span ${tile.h}`;
}
function minSizeOf(tile) {
const min = tile.minSize || registry.get(tile.module)?.minSize;
return {
w: Math.max(1, min?.w || 1),
h: Math.max(1, min?.h || 1),
};
}
/* ── title bars that fold away ──────────────────────────────────────── */
2026-09-17 12:04:59 -04:00
/* A bar is "off" when it is folded out of sight: the dashboard has auto-hide
on and this tile is not pinned, or the screen is in fullscreen, which folds
every bar for watching whatever the tiles say. Off is never gone — the top
edge of the tile brings it back for as long as the pointer is there. */
/** How close to the tile's top edge the pointer opens a folded-away bar. */
const HEAD_REVEAL_PX = 22;
/** How far down it may wander before that bar folds away again (bar + a little). */
const HEAD_HOLD_PX = 38;
function headIsOff(tile) {
2026-09-17 12:04:59 -04:00
if (document.body.classList.contains('fullscreen')) return true;
return autoHideHeads && !tile.headPinned;
}
function applyHeadState(tile, el = tileEls.get(tile.id)?.el) {
if (!el) return;
const off = headIsOff(tile);
el.classList.toggle('head-off', off);
if (!off) el.classList.remove('head-peek');
}
/** Re-read every tile's bar — after the toggle, or after fullscreen changes. */
function applyAllHeadStates() {
for (const tile of tiles) applyHeadState(tile);
}
function setAutoHideHeads(on, { save = true } = {}) {
autoHideHeads = Boolean(on);
document.body.classList.toggle('autohide-heads', autoHideHeads);
autoHideBtn?.classList.toggle('is-on', autoHideHeads);
autoHideBtn?.setAttribute('aria-pressed', autoHideHeads ? 'true' : 'false');
applyAllHeadStates();
if (save) saveLayout();
}
/* ── tile DOM ───────────────────────────────────────────────────────── */
const CLOSE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
2026-09-17 12:04:59 -04:00
const PIN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16.5V22"/><path class="pin-head" d="M8.5 3h7l-1.2 7 3.2 3.2H6.5L9.7 10 8.5 3z"/></svg>';
2026-09-15 10:19:31 -04:00
const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>';
/* ── what a saved tile can be shown as ─────────────────────────────────
A layout keeps every tile it was given; the board shows the ones whose
module is installed and enabled and — for a tile added from a picker
entry — whose entry (or an alias of it) the module offers right now. The
rest keep their place and draw nothing, and appear the moment the
registry offers them again: a Service timer waits for PCO Plan, a
transcript for its channel. Never the module's default in their place —
a wall of "All timers" where five different timers were is worse than
five gaps that fill themselves in. */
function tileIsOffered(tile) {
const man = registry.get(tile.module);
if (!man) return false;
const variant = String(tile.variant || '');
if (!variant) return true; // the plain module tile — the module says what that means
const entries = Array.isArray(man.tiles) ? man.tiles : [];
// No list at all is a module whose tiles() did not answer in time (the
// server falls back to the classic single entry): mount, and let it sort
// its own variant out rather than blank the board over a slow answer.
if (!entries.length) return true;
return entries.some((e) => String(e.id) === variant || (Array.isArray(e.aliases) && e.aliases.includes(variant)));
}
/** Show tiles that have become offered; fold away tiles that have stopped being. */
function reconcileOfferedTiles() {
let changed = false;
for (const tile of tiles) {
const shown = tileEls.has(tile.id);
const offered = tileIsOffered(tile);
if (offered && !shown) {
buildTile(tile);
changed = true;
} else if (!offered && shown) {
unmountModule(tile);
tileEls.get(tile.id)?.el.remove();
tileEls.delete(tile.id);
changed = true;
}
}
if (changed) rebuildDividers();
scheduleOfferedRecheck();
}
/* A module's entry list can change without anyone saving admin config: PCO
Plan comes back and, a few seconds later, the timers module is offering
its timers again. No event marks that moment, so while any tile is folded
away the registry is re-read every few seconds until nothing is. It costs
one GET while something is hidden and nothing at all otherwise. */
const OFFERED_RECHECK_MS = 6000;
let offeredRecheck = 0;
function scheduleOfferedRecheck() {
clearTimeout(offeredRecheck);
if (!tiles.some((t) => !tileEls.has(t.id))) return;
offeredRecheck = setTimeout(async () => {
offeredRecheck = 0;
if (await loadRegistry()) reconcileOfferedTiles();
else scheduleOfferedRecheck();
}, OFFERED_RECHECK_MS);
}
/** The tiles a layout holds that the board is not showing, by name. */
function unshownTileNames() {
return tiles.filter((t) => !tileEls.has(t.id))
.map((t) => t.title || registry.get(t.module)?.name || t.module);
}
function buildTile(tile) {
const man = registry.get(tile.module);
const el = document.createElement('section');
el.className = 'tile';
el.dataset.tileId = tile.id;
if (man) el.dataset.module = man.id;
applyTint(tile, el);
const head = document.createElement('div');
head.className = 'tile-head';
const dot = document.createElement('span');
dot.className = 'dot';
dot.title = '';
const title = document.createElement('span');
title.className = 'tile-title';
title.textContent = man ? (tile.title || man.name) : tile.module + ' (not installed)';
// where moduleApi.header puts a module's own buttons and menus
const controls = document.createElement('span');
controls.className = 'tile-controls';
2026-09-17 12:04:59 -04:00
/* Pinned tiles keep their bar through auto-hide. The switch sits right
beside the label rather than out with the actions, because it is about
this bar rather than about the tile's contents. */
const pin = document.createElement('button');
pin.className = 'tile-btn pin';
pin.innerHTML = PIN_SVG;
const paintPin = () => {
const on = Boolean(tile.headPinned);
pin.classList.toggle('is-pinned', on);
pin.setAttribute('aria-pressed', on ? 'true' : 'false');
// Instant tooltips read `title` the first time and `data-tip` after.
pin.title = on ? 'Unpin bar' : 'Pin bar';
pin.setAttribute('data-tip', pin.title);
};
paintPin();
pin.addEventListener('click', (e) => {
e.stopPropagation();
tile.headPinned = !tile.headPinned;
paintPin();
applyHeadState(tile, el);
saveLayout();
});
// Keeps the label hugging the pin however wide the tile gets.
const gap = document.createElement('span');
gap.className = 'tile-head-gap';
head.append(dot, title, pin, gap, controls);
// Every tile has something to set: its own settings when the module
// offers any, and the tint either way.
if (man) {
const gear = document.createElement('button');
gear.className = 'tile-btn';
gear.title = 'Settings';
gear.innerHTML = GEAR_SVG;
gear.addEventListener('click', (e) => {
e.stopPropagation();
toggleSettingsPopover(tile, el, gear);
});
head.appendChild(gear);
}
const close = document.createElement('button');
close.className = 'tile-btn close';
close.title = 'Remove';
close.innerHTML = CLOSE_SVG; // a drawn cross, the same size in every browser (the ✕ glyph varies)
close.addEventListener('click', (e) => {
e.stopPropagation();
removeTile(tile.id);
});
head.appendChild(close);
const body = document.createElement('div');
body.className = 'tile-body';
/* The rim: both bottom corners resize in two directions, the bottom edge
moves that edge (trading rows with tiles below), and the other three
move only themselves — the top edge changes where the tile starts, the
sides how wide it is — each stopping where a neighbour or the tile's own
minimum says it must. None of them draw anything: the cursor over an
edge is what says it can be dragged, as on a window. */
const grip = (cls) => {
const gripEl = document.createElement('div');
gripEl.className = cls;
return gripEl;
};
const resize = grip('tile-resize');
const resizeLeft = grip('tile-resize-left');
const resizeBottom = grip('tile-resize-bottom');
const edgeTop = grip('tile-resize-top');
const edgeRight = grip('tile-resize-right');
const edgeLeft = grip('tile-resize-side');
// A folded-away bar is still reachable: point at the tile's top edge and it
// slides in over the content, where its buttons click and its whole width
// drags the tile exactly as when it is showing.
el.addEventListener('pointermove', (ev) => {
if (!headIsOff(tile)) return;
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
// — neither is a moment to take the bar away.
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
const y = ev.clientY - el.getBoundingClientRect().top;
const shown = el.classList.contains('head-peek');
// It takes less to hold the bar open than to open it, so a bar that
// appears under the pointer cannot flicker straight back out.
if (!shown && y <= HEAD_REVEAL_PX) el.classList.add('head-peek');
else if (shown && y > HEAD_HOLD_PX) el.classList.remove('head-peek');
});
el.addEventListener('pointerleave', () => {
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
2026-09-17 12:04:59 -04:00
el.classList.remove('head-peek');
});
2026-09-17 12:04:59 -04:00
el.append(head, body, resize, resizeLeft, resizeBottom, edgeTop, edgeRight, edgeLeft);
applyHeadState(tile, el);
grid.appendChild(el);
const entry = { el, body, dot, titleEl: title, controls, instance: null };
tileEls.set(tile.id, entry);
applyRect(tile);
wireDrag(tile, el, head);
wireResize(tile, el, resize, false);
wireResize(tile, el, resizeLeft, true);
wireBottomResize(tile, el, resizeBottom);
wireEdgeResize(tile, el, edgeTop, 'n');
wireEdgeResize(tile, el, edgeRight, 'e');
wireEdgeResize(tile, el, edgeLeft, 'w');
mountModule(tile);
return entry;
}
function refreshEmptyState() {
emptyEl.hidden = tiles.length > 0;
}
/* ── drag to move ───────────────────────────────────────────────────── */
function wireDrag(tile, el, handle) {
handle.addEventListener('pointerdown', (ev) => {
2026-09-17 12:04:59 -04:00
if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu')) return;
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault();
closeSettingsPopovers();
handle.setPointerCapture(ev.pointerId);
const m = cellMetrics();
const sx = ev.clientX;
const sy = ev.clientY;
const startX = tile.x;
const startY = tile.y;
let moved = false;
const onMove = (e) => {
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
if (!moved && (dCols || dRows)) {
moved = true;
el.classList.add('dragging');
grid.classList.add('no-dividers');
}
const cand = {
x: Math.max(0, Math.min(COLS - tile.w, startX + dCols)),
y: Math.max(0, startY + dRows),
w: tile.w,
h: tile.h,
};
if ((cand.x !== tile.x || cand.y !== tile.y) && isFree(cand, tile.id)) {
tile.x = cand.x;
tile.y = cand.y;
applyRect(tile);
}
};
const onUp = () => {
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onUp);
el.classList.remove('dragging');
grid.classList.remove('no-dividers');
if (moved) {
saveLayout();
rebuildDividers();
}
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
});
}
/* ── drag to resize (bottom-right and bottom-left corners) ──────────── */
function wireResize(tile, el, handle, fromLeft) {
handle.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault();
ev.stopPropagation();
handle.setPointerCapture(ev.pointerId);
const m = cellMetrics();
const sx = ev.clientX;
const sy = ev.clientY;
const startX = tile.x;
const startW = tile.w;
const startH = tile.h;
const min = minSizeOf(tile);
el.classList.add('resizing');
grid.classList.add('no-dividers');
let changed = false;
const onMove = (e) => {
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
let cand;
if (fromLeft) {
// the left edge moves; the right edge stays put
const newX = Math.max(0, Math.min(startX + startW - min.w, startX + dCols));
cand = {
x: newX,
y: tile.y,
w: startX + startW - newX,
h: Math.max(min.h, startH + dRows),
};
} else {
cand = {
x: tile.x,
y: tile.y,
w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)),
h: Math.max(min.h, startH + dRows),
};
}
if ((cand.x !== tile.x || cand.w !== tile.w || cand.h !== tile.h) && isFree(cand, tile.id)) {
tile.x = cand.x;
tile.w = cand.w;
tile.h = cand.h;
applyRect(tile);
changed = true;
notifyResize(tile);
}
};
const onUp = () => {
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onUp);
el.classList.remove('resizing');
grid.classList.remove('no-dividers');
if (changed) {
saveLayout();
rebuildDividers();
}
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
});
}
/* ── drag the top, left or right edge ───────────────────────────────── */
/* One edge moves and the opposite one stays where it is, so the tile grows
or shrinks into the space beside it. Unlike the bottom-edge pill these do
not trade rows or columns with a neighbour: the edge simply stops when it
meets one, which is what "resize this tile" should mean. */
function wireEdgeResize(tile, el, handle, edge) {
handle.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault();
ev.stopPropagation();
closeSettingsPopovers();
handle.setPointerCapture(ev.pointerId);
const m = cellMetrics();
const sx = ev.clientX;
const sy = ev.clientY;
const startX = tile.x;
const startY = tile.y;
const startW = tile.w;
const startH = tile.h;
const min = minSizeOf(tile);
el.classList.add('resizing');
grid.classList.add('no-dividers');
let changed = false;
const onMove = (e) => {
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
let cand;
if (edge === 'n') {
// the top edge moves; the bottom edge stays put
const y = Math.max(0, Math.min(startY + startH - min.h, startY + dRows));
cand = { x: tile.x, y, w: tile.w, h: startY + startH - y };
} else if (edge === 'w') {
// the left edge moves; the right edge stays put
const x = Math.max(0, Math.min(startX + startW - min.w, startX + dCols));
cand = { x, y: tile.y, w: startX + startW - x, h: tile.h };
} else {
cand = {
x: tile.x,
y: tile.y,
w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)),
h: tile.h,
};
}
if (cand.x === tile.x && cand.y === tile.y && cand.w === tile.w && cand.h === tile.h) return;
if (!isFree(cand, tile.id)) return;
tile.x = cand.x;
tile.y = cand.y;
tile.w = cand.w;
tile.h = cand.h;
applyRect(tile);
changed = true;
notifyResize(tile);
};
const onUp = () => {
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onUp);
el.classList.remove('resizing');
grid.classList.remove('no-dividers');
if (changed) {
saveLayout();
rebuildDividers();
}
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
});
}
/* ── drag the bottom edge: height only ──────────────────────────────── */
/* The pill on a tile's bottom edge moves that edge. When other tiles sit
directly below it (sharing the edge), the drag trades rows with them —
the column's total height stays the same, like the seam pill trades
columns. With nothing directly below, it simply changes this tile's
height. */
function tilesDirectlyBelow(tile) {
return tiles.filter((t) => t !== tile
&& t.y === tile.y + tile.h
&& t.x < tile.x + tile.w && tile.x < t.x + t.w);
}
function wireBottomResize(tile, el, handle) {
handle.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault();
ev.stopPropagation();
closeSettingsPopovers();
handle.setPointerCapture(ev.pointerId);
const m = cellMetrics();
const sy = ev.clientY;
const startH = tile.h;
const below = tilesDirectlyBelow(tile).map((t) => ({ t, bottom: t.y + t.h }));
const group = [tile.id, ...below.map((b) => b.t.id)];
const minH = minSizeOf(tile).h;
// the edge can rise until this tile hits its minimum, and drop until the
// shortest neighbour below hits its own
const maxH = below.length
? Math.min(...below.map((b) => b.bottom - minSizeOf(b.t).h)) - tile.y
: Infinity;
el.classList.add('resizing');
grid.classList.add('no-dividers');
let changed = false;
const onMove = (e) => {
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
const h = Math.max(minH, Math.min(maxH, startH + dRows));
if (h === tile.h || h > maxH) return;
const edge = tile.y + h;
const candSelf = { x: tile.x, y: tile.y, w: tile.w, h };
const candBelow = below.map((b) => ({ x: b.t.x, y: edge, w: b.t.w, h: b.bottom - edge }));
if (!isFreeExcept(candSelf, group) || candBelow.some((c) => !isFreeExcept(c, group))) return;
tile.h = h;
applyRect(tile);
notifyResize(tile);
below.forEach((b, i) => {
b.t.y = candBelow[i].y;
b.t.h = candBelow[i].h;
applyRect(b.t);
notifyResize(b.t);
});
changed = true;
};
const onUp = () => {
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onUp);
el.classList.remove('resizing');
grid.classList.remove('no-dividers');
if (changed) {
saveLayout();
rebuildDividers();
}
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
});
}
function notifyResize(tile) {
const entry = tileEls.get(tile.id);
try {
entry?.instance?.onResize?.(tile.w, tile.h);
} catch { /* module's problem, not the shell's */ }
}
/* ── seam dividers between adjacent tiles ───────────────────────────── */
/* Wherever two tiles share a vertical edge, a divider sits on the seam.
Dragging one moves that column boundary wherever it appears: every pair of
tiles meeting at the same x, in every row, moves with it, so a column of
tiles keeps its edge as one line rather than drifting row by row. Hovering
a seam lights up the others that travel with it.
Each pair trades width within its own span — the pair's combined span never
changes — so nothing else can collide except via the explicit check below
(a third tile can sit behind the seam for part of its height). A row where
one tile simply spans the boundary has no seam there and stays put. */
/** Live dividers, so a drag can reposition all of them as the tiles move. */
const dividers = [];
function rebuildDividers() {
grid.querySelectorAll('.tile-divider').forEach((el) => el.remove());
dividers.length = 0;
for (const L of tiles) {
for (const R of tiles) {
if (L === R || L.x + L.w !== R.x) continue;
if (!tileEls.has(L.id) || !tileEls.has(R.id)) continue;
const top = Math.max(L.y, R.y);
const bottom = Math.min(L.y + L.h, R.y + R.h);
if (bottom <= top) continue;
buildDivider(L, R, top, bottom);
}
}
}
/** Every pair of tiles that meets at this column boundary, in any row. */
function seamPairsAt(boundary) {
const pairs = [];
for (const L of tiles) {
if (L.x + L.w !== boundary) continue;
for (const R of tiles) {
if (R === L || R.x !== boundary) continue;
if (Math.min(L.y + L.h, R.y + R.h) <= Math.max(L.y, R.y)) continue;
pairs.push({ L, R });
}
}
return pairs;
}
function buildDivider(L, R, top, bottom) {
const el = document.createElement('div');
el.className = 'tile-divider';
const place = () => {
const m = cellMetrics();
// left as a percentage so the pill tracks window resizes for free
el.style.left = ((L.x + L.w) / COLS) * 100 + '%';
el.style.top = top * (m.ch + m.gap) + 'px';
el.style.height = (bottom - top) * (m.ch + m.gap) - m.gap + 'px';
};
place();
const boundary = () => L.x + L.w;
const groupEls = () => dividers.filter((d) => d.boundary() === boundary()).map((d) => d.el);
el.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault();
closeSettingsPopovers();
el.setPointerCapture(ev.pointerId);
const m = cellMetrics();
const sx = ev.clientX;
const startBoundary = boundary();
// Every row that has a seam here moves with it. Each pair's outer edges
// are fixed for the whole drag, so each row only trades width internally.
const parts = seamPairsAt(startBoundary).map(({ L: a, R: b }) => ({
L: a, R: b, leftX: a.x, rightEdge: b.x + b.w,
}));
if (!parts.length) return;
const moving = parts.flatMap((p) => [p.L.id, p.R.id]);
// The whole column stops at whichever row runs out of room first.
const minB = Math.max(...parts.map((p) => p.leftX + minSizeOf(p.L).w));
const maxB = Math.min(...parts.map((p) => p.rightEdge - minSizeOf(p.R).w));
const group = groupEls();
for (const other of group) other.classList.add('active');
let changed = false;
const onMove = (e) => {
const b = Math.max(minB, Math.min(maxB,
startBoundary + Math.round((e.clientX - sx) / (m.cw + m.gap))));
if (b === boundary()) return;
const next = parts.map((p) => ({
p,
candL: { x: p.leftX, y: p.L.y, w: b - p.leftX, h: p.L.h },
candR: { x: b, y: p.R.y, w: p.rightEdge - b, h: p.R.h },
}));
// All rows move or none do — a column edge that bends is worse than one
// that won't budge.
if (next.some(({ candL, candR }) =>
!isFreeExcept(candL, moving) || !isFreeExcept(candR, moving))) return;
for (const { p, candL, candR } of next) {
p.L.w = candL.w;
p.R.x = candR.x;
p.R.w = candR.w;
applyRect(p.L);
applyRect(p.R);
notifyResize(p.L);
notifyResize(p.R);
}
for (const d of dividers) d.place();
changed = true;
};
const onUp = () => {
el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp);
for (const other of group) other.classList.remove('active');
if (changed) saveLayout();
rebuildDividers(); // adjacencies may have changed
};
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerup', onUp);
el.addEventListener('pointercancel', onUp);
});
dividers.push({ el, place, boundary });
grid.appendChild(el);
}
/* ── add / remove tiles ─────────────────────────────────────────────── */
/**
* Add a tile. `entry` is one item of a multi-tile module's tile list
* (picker variants): its preset settings overlay the schema defaults, and
* its name/sizes stick to the tile.
*/
function addTile(moduleId, entry = null) {
const man = registry.get(moduleId);
if (entry?.single && tiles.some((t) => t.module === moduleId && t.variant === String(entry.id))) {
toast(`${entry.name} is already on this dashboard`);
return;
}
// Sizes are declared for the default twelve-column grid; scaled so a new
// tile lands at about the size its author meant, whatever the density here.
const scale = COLS / GRID_DEFAULT;
const defaultSize = entry?.defaultSize || man?.defaultSize;
const floor = minSizeOf({ module: moduleId, minSize: entry?.minSize });
const size = {
w: Math.max(floor.w, 1, Math.min(COLS, Math.round((defaultSize?.w || 4) * scale))),
h: Math.max(floor.h, 1, Math.round((defaultSize?.h || 3) * scale)),
};
const spot = findSpot(size.w, size.h);
const settings = {};
const { schema: addSchema } = tileSchema({ variant: entry ? String(entry.id) : '' }, man);
for (const [key, spec] of Object.entries(addSchema)) {
if (spec && 'default' in spec) settings[key] = spec.default;
}
Object.assign(settings, entry?.settings || {});
2026-09-17 12:04:59 -04:00
const tile = { id: newId(), module: moduleId, ...spot, ...size, settings, headPinned: false };
if (entry) {
tile.variant = String(entry.id);
tile.title = String(entry.name || '');
if (entry.minSize) tile.minSize = { w: entry.minSize.w, h: entry.minSize.h };
}
tiles.push(tile);
buildTile(tile);
refreshEmptyState();
saveLayout();
rebuildDividers();
}
function removeTile(id) {
const idx = tiles.findIndex((t) => t.id === id);
if (idx < 0) return;
unmountModule(tiles[idx]);
tiles.splice(idx, 1);
const entry = tileEls.get(id);
if (entry) {
entry.el.remove();
tileEls.delete(id);
}
refreshEmptyState();
saveLayout();
rebuildDividers();
}
function clearAllTiles() {
for (const tile of tiles) {
unmountModule(tile);
tileEls.get(tile.id)?.el.remove();
}
tiles = [];
tileEls.clear();
refreshEmptyState();
}
/* ── module mounting ────────────────────────────────────────────────── */
const clientModuleCache = new Map(); // module id -> Promise<ES module>
function loadClientModule(man) {
if (!clientModuleCache.has(man.id)) {
clientModuleCache.set(man.id, import(`/modules/${man.id}/${man.client || 'client.js'}`));
}
return clientModuleCache.get(man.id);
}
/** Inject a module's stylesheet once (shared by every instance of it). */
function ensureModuleStyle(man) {
if (!man.style) return;
const id = 'module-style-' + man.id;
if (document.getElementById(id)) return;
const link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
link.href = `/modules/${man.id}/${man.style}`;
document.head.appendChild(link);
}
function tileMessage(entry, text) {
const msg = document.createElement('div');
msg.className = 'tile-msg';
msg.textContent = text;
entry.body.appendChild(msg);
}
2026-09-15 11:02:36 -04:00
/* ── one stream per page ─────────────────────────────────────────────── */
/* Browsers allow about six HTTP/1.1 connections to one host, and an open
2026-09-15 11:02:36 -04:00
EventSource holds one of them for as long as it lives. Sharing one
stream per URL was not enough: a dashboard with six modules still ran
out (Safari first), and from then on every further request — a remounted
tile's /state, an avatar, the + picker's list — sat in the browser's
queue waiting for a connection that was never handed back. So the page
holds ONE EventSource, GET /api/stream?s=<keys>, and the server relays
every stream named in it (a module's, or `shell:` for the shell's own)
with the key prefixed to each event name. Subscribers still see the
events they asked for by their own names. Adding or dropping a stream
remakes the connection with the new list, a moment later so several
tiles mounting together cause one reconnect; every stream announces
`open` again after it, so backfills run as before. */
2026-09-15 11:02:36 -04:00
const muxSubs = new Map(); // key -> { subs: Set<{ handlers }> }
let muxEs = null; // the page's one EventSource
let muxKeys = ''; // the key list it was opened with
let muxTimer = null; // the pending (re)connect
let muxRetry = null; // retry after the connection gave up
const muxListened = new Set(); // "key|name" pairs with a listener on muxEs
/** The stream key for a URL: `/api/modules/<id><path>` → `<id>:<path>`, `/api/events` → `shell:`. */
function muxKeyFor(url) {
if (url === '/api/events') return 'shell:';
const m = /^\/api\/modules\/([^/?#]+)(\/[^?#]*)?$/.exec(url);
return m ? `${decodeURIComponent(m[1])}:${m[2] || '/'}` : null;
}
function muxFanOut(key, kind, e) {
for (const sub of [...(muxSubs.get(key)?.subs || [])]) {
const fn = kind === 'event' ? sub.handlers.events?.[e.type] : sub.handlers[kind];
try { fn?.(e); } catch (err) { console.error('[shell] stream handler failed:', err); }
}
}
/** Listen for `<key>|<name>` on the open connection and hand it to that key's subscribers as `name`. */
function muxListen(key, name) {
const full = `${key}|${name}`;
if (!muxEs || muxListened.has(full)) return;
muxListened.add(full);
muxEs.addEventListener(full, (e) => {
if (name === '__open') return muxFanOut(key, 'open', new Event('open'));
if (name === '__down') return muxFanOut(key, 'error', new Event('error'));
const ev = { type: name, data: e.data, lastEventId: e.lastEventId };
muxFanOut(key, name === 'message' ? 'message' : 'event', ev);
});
}
function muxConnect() {
clearTimeout(muxTimer);
muxTimer = null;
clearTimeout(muxRetry);
muxRetry = null;
const keys = [...muxSubs.keys()].sort();
const wanted = keys.join(',');
if (!keys.length) {
try { muxEs?.close(); } catch { /* not open */ }
muxEs = null;
muxKeys = '';
return;
}
if (muxEs && muxKeys === wanted && muxEs.readyState !== EventSource.CLOSED) return;
try { muxEs?.close(); } catch { /* not open */ }
muxListened.clear();
muxKeys = wanted;
const es = new EventSource(`/api/stream?s=${encodeURIComponent(wanted)}`);
muxEs = es;
// Each stream says `open` for itself (the server's __open), so nothing to do on the connection's own open.
es.onerror = () => {
if (es !== muxEs) return;
for (const key of muxSubs.keys()) muxFanOut(key, 'error', new Event('error'));
// EventSource retries transient drops itself but gives up for good on a
// completed non-SSE response (a 502 while the server restarts).
if (es.readyState === EventSource.CLOSED && muxSubs.size) {
clearTimeout(muxRetry);
muxRetry = setTimeout(muxConnect, 3000);
}
};
for (const [key, entry] of muxSubs) {
muxListen(key, '__open');
muxListen(key, '__down');
muxListen(key, 'message');
for (const sub of entry.subs) for (const name of Object.keys(sub.handlers.events || {})) muxListen(key, name);
}
}
function sseSubscribe(url, handlers = {}) {
2026-09-15 11:02:36 -04:00
const key = muxKeyFor(url);
if (!key) {
console.warn('[shell] not a stream this page can carry:', url);
return { close() {} };
}
let entry = muxSubs.get(key);
if (!entry) {
entry = { subs: new Set() };
muxSubs.set(key, entry);
}
const sub = { handlers };
2026-09-15 11:02:36 -04:00
entry.subs.add(sub);
const carried = Boolean(muxEs) && muxKeys.split(',').includes(key) && muxEs.readyState !== EventSource.CLOSED;
if (carried) {
// Joining a stream the connection already carries: listeners for any
// new event names, and `open` a moment later so the caller's own setup
// finishes first — exactly as a fresh connection would have reported it.
for (const name of Object.keys(handlers.events || {})) muxListen(key, name);
if (muxEs.readyState === EventSource.OPEN) {
setTimeout(() => {
if (!entry.subs.has(sub)) return;
try { handlers.open?.(new Event('open')); } catch (err) { console.error('[shell] stream handler failed:', err); }
}, 0);
}
} else {
clearTimeout(muxTimer);
muxTimer = setTimeout(muxConnect, 20);
}
return {
close() {
2026-09-15 11:02:36 -04:00
if (!entry.subs.delete(sub) || entry.subs.size) return;
muxSubs.delete(key);
// A remount closes and reopens within milliseconds: wait before remaking the connection.
clearTimeout(muxTimer);
muxTimer = setTimeout(muxConnect, 500);
},
};
}
/**
* The API handed to each module instance — the whole surface a module may
* touch outside its root element. See docs/MODULE-GUIDE.md.
*/
function buildModuleApi(tile, man, entry) {
const sseHandles = new Set();
const headerEls = new Set(); // controls this instance added to the title bar
const docListeners = new Set(); // outside-click closers for header menus
const api = {
id: man.id,
instanceId: tile.id,
/** For multi-tile modules: which entry of the module's tile list this
tile was added as ('' when added as the plain module). */
variant: tile.variant || '',
/** Admin (server-wide) config, read-only. Password fields never reach
the client. Live: reflects the latest registry after admin saves. */
get config() {
return { ...((registry.get(man.id) || man).config || {}) };
},
/** Per-tile settings: schema defaults overlaid with what this tile saved. */
get instanceSettings() {
const out = {};
for (const [key, spec] of Object.entries(tileSchema(tile, man).schema)) {
if (spec && 'default' in spec) out[key] = spec.default;
}
return Object.assign(out, tile.settings || {});
},
/** Persist per-tile settings into the layout (no remount — the module
already knows, it made the change). */
saveInstanceSettings(patch) {
Object.assign(tile.settings, patch || {});
saveLayout();
},
/** fetch scoped to this module's server routes. */
fetch(subPath, opts) {
return fetch('/api/modules/' + man.id + subPath, opts);
},
/**
* EventSource scoped the same way, with auto-reconnect: a stream that
* gives up (a 502 while the upstream is down) is recreated on a timer
* until it works again. Tiles asking for the same stream share one
* connection — see the pool above; a module sees no difference.
* handlers: { open(e), error(e), message(e), events: { name: fn } }
*/
sse(subPath, handlers = {}) {
const shared = sseSubscribe('/api/modules/' + man.id + subPath, handlers);
const handle = {
close() {
shared.close();
sseHandles.delete(handle);
},
};
sseHandles.add(handle);
return handle;
},
/** Drive the tile's status dot: 'ok' | 'connecting' | 'error'. */
setStatus(state, msg = '') {
const cls = state === 'ok' || state === 'connecting' || state === 'error' ? state : '';
entry.dot.className = 'dot ' + cls;
entry.dot.title = msg;
},
/**
* The tile's title bar. Controls added here live next to the gear/close
* buttons and are removed automatically when the instance stops. The
2026-09-17 12:04:59 -04:00
* whole bar folds away when the dashboard auto-hides it, so nothing
* essential should exist ONLY as a header control.
*/
header: {
/** A small button. icon = inline SVG string, label = short text (either or both). */
addButton({ icon = '', label = '', title = '', onClick } = {}) {
const btn = document.createElement('button');
btn.className = 'tile-btn module-btn';
btn.title = title || label;
if (icon) btn.innerHTML = icon;
if (label) btn.appendChild(document.createTextNode(label));
if (typeof onClick === 'function') {
btn.addEventListener('click', (e) => {
e.stopPropagation();
onClick(e);
});
}
entry.controls.appendChild(btn);
headerEls.add(btn);
return btn;
},
/**
* A dropdown menu button. build(menuEl) runs on every open and fills
* the (emptied) menu element. Returns { button, menu, setLabel, close }.
*/
addMenu({ icon = '', label = '', title = '', build } = {}) {
const wrap = document.createElement('span');
wrap.className = 'tile-dd';
const btn = document.createElement('button');
btn.className = 'tile-btn module-btn';
btn.title = title || label;
if (icon) btn.innerHTML = icon;
if (label) btn.appendChild(document.createTextNode(label));
const menu = document.createElement('div');
menu.className = 'tile-menu';
menu.hidden = true;
const open = () => {
if (typeof build === 'function') {
menu.innerHTML = '';
build(menu);
}
menu.hidden = false;
// fixed positioning (escapes the tile's overflow): under the
// button, right-aligned, clamped to the viewport
const r = btn.getBoundingClientRect();
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
menu.style.left = '0px';
const w = menu.offsetWidth;
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
};
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.hidden) open();
else menu.hidden = true;
});
const onDocClick = (e) => {
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true;
};
document.addEventListener('click', onDocClick);
docListeners.add(onDocClick);
wrap.append(btn, menu);
entry.controls.appendChild(wrap);
headerEls.add(wrap);
return {
button: btn,
menu,
setLabel(text) {
btn.textContent = text;
},
close() {
menu.hidden = true;
},
};
},
},
/* shell-internal: safety net so a forgotten stream, header control or
listener can't outlive the tile */
_closeAll() {
for (const handle of [...sseHandles]) handle.close();
for (const el of headerEls) el.remove();
headerEls.clear();
for (const fn of docListeners) document.removeEventListener('click', fn);
docListeners.clear();
},
};
return api;
}
async function mountModule(tile) {
const entry = tileEls.get(tile.id);
if (!entry) return;
const man = registry.get(tile.module);
if (!man) {
tileMessage(entry, `Module “${tile.module}” is not installed or is disabled. Check Admin.`);
entry.dot.className = 'dot error';
entry.dot.title = 'Module unavailable';
return;
}
entry.dot.className = 'dot connecting';
ensureModuleStyle(man);
try {
const mod = await loadClientModule(man);
if (tileEls.get(tile.id) !== entry || entry.instance) return; // tile removed/remounted while loading
const factory = mod.default;
if (typeof factory !== 'function') throw new Error('client.js has no default-export factory');
const api = buildModuleApi(tile, man, entry);
const instance = factory({ root: entry.body, moduleApi: api }) || {};
entry.instance = instance;
entry.api = api;
instance.start?.();
} catch (err) {
console.error(`[shell] module "${tile.module}" failed to start:`, err);
entry.body.innerHTML = '';
tileMessage(entry, `“${man.name}” failed to start: ${err?.message || err}`);
entry.dot.className = 'dot error';
}
}
function unmountModule(tile) {
const entry = tileEls.get(tile.id);
if (!entry) return;
try {
entry.instance?.stop?.();
} catch { /* stopping is best-effort */ }
try {
entry.api?._closeAll();
} catch { /* ditto */ }
entry.instance = null;
entry.api = null;
}
/* ── per-tile settings popover ──────────────────────────────────────── */
/** `<input type="color">` only accepts #rrggbb — anything else falls back. */
/* ── the tile tint ───────────────────────────────────────────────────
Every tile can be washed in a colour of its own — a way to tell one
screen's tiles apart at a glance (this campus green, that one amber).
It is the shell's, not a module's: the key is reserved, it is offered in
every gear window, and it saves with the layout like any other tile
setting. A tile with no tint looks exactly as before. */
const TINT_KEY = '__tint';
/** Paint (or clear) a tile's tint on its element. */
function applyTint(tile, el = tileEls.get(tile.id)?.el) {
if (!el) return;
const hex = colorHex(tile.settings?.[TINT_KEY], '');
if (hex) el.style.setProperty('--tile-tint', hex);
else el.style.removeProperty('--tile-tint');
el.classList.toggle('is-tinted', Boolean(hex));
}
function colorHex(value, fallback) {
const s = String(value ?? '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase();
if (/^#[0-9a-fA-F]{3}$/.test(s)) return ('#' + s.slice(1).split('').map((c) => c + c).join('')).toLowerCase();
return fallback;
}
/** The settings a tile offers: its picker entry's own schema when it has one
— a solo timer wants none of a full tile's switches — else the module's.
Same for the groups that arrange them. */
function tileSchema(tile, man) {
const own = (v) => (v && typeof v === 'object' ? v : null);
const variant = String(tile?.variant || '');
const entry = variant && Array.isArray(man?.tiles)
? man.tiles.find((e) => String(e.id) === variant)
|| man.tiles.find((e) => Array.isArray(e.aliases) && e.aliases.includes(variant))
: null;
return {
schema: own(entry?.instanceSchema) || own(man?.instanceSchema) || {},
groups: own(entry?.instanceGroups) || own(man?.instanceGroups) || {},
};
}
/* ── tooltips: at once on hover, short, one for the whole page ──────────
Every header and top-bar button's `title` becomes a tip the shell shows
the moment the pointer arrives — the browser's own waits about a second
and looks different in each browser. The text moves from `title` to
data-tip so the native one never appears; a module that sets `title`
again later is picked up on the next hover. */
let tipEl = null;
const TIP_TARGETS = '.tile-btn, .tb-btn, [data-tip]';
function tipTextFor(el) {
if (el.title) {
el.dataset.tip = el.title;
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', el.title);
el.removeAttribute('title');
}
return el.dataset.tip || '';
}
function showTip(el) {
const text = tipTextFor(el);
if (!text) return;
if (!tipEl) {
tipEl = document.createElement('div');
tipEl.id = 'tip';
tipEl.setAttribute('role', 'tooltip');
document.body.appendChild(tipEl);
}
tipEl.textContent = text;
tipEl.hidden = false;
const r = el.getBoundingClientRect();
const w = tipEl.offsetWidth;
const h = tipEl.offsetHeight;
const left = Math.max(6, Math.min(r.left + r.width / 2 - w / 2, window.innerWidth - w - 6));
let top = r.bottom + 6;
if (top + h > window.innerHeight - 6) top = r.top - h - 6;
tipEl.style.left = `${Math.round(left)}px`;
tipEl.style.top = `${Math.round(top)}px`;
tipEl._for = el;
}
function hideTip(el = null) {
if (!tipEl || tipEl.hidden) return;
if (el && tipEl._for !== el) return;
tipEl.hidden = true;
tipEl._for = null;
}
document.addEventListener('pointerover', (e) => {
const el = e.target?.closest?.(TIP_TARGETS);
if (!el || (tipEl && tipEl._for === el && !tipEl.hidden)) return;
showTip(el);
});
document.addEventListener('pointerout', (e) => {
const el = e.target?.closest?.(TIP_TARGETS);
if (!el || (e.relatedTarget && el.contains(e.relatedTarget))) return;
hideTip(el);
});
document.addEventListener('pointerdown', () => hideTip(), true);
document.addEventListener('focusin', (e) => {
const el = e.target?.closest?.(TIP_TARGETS);
if (el) showTip(el);
});
document.addEventListener('focusout', () => hideTip());
window.addEventListener('scroll', () => hideTip(), true);
window.addEventListener('resize', () => hideTip());
function closeSettingsPopovers() {
document.querySelectorAll('.tile-settings').forEach((p) => {
p._cleanup?.();
p._button?.classList.remove('is-open');
p.remove();
});
}
/* ── the per-tile settings window ───────────────────────────────────── */
/* It opens beside its tile — to the right when there is room, else the
left, else over it — and always inside the viewport. A window rather than
something inside the tile, because a tile can be smaller than its own
settings, and because the point of live settings is watching the tile
change while you change them: the window must not sit on top of it. */
function placePopover(pop, anchor) {
const vw = window.innerWidth;
const vh = window.innerHeight;
const pad = 8;
const w = pop.offsetWidth;
const h = pop.offsetHeight;
let left = anchor.right + pad;
if (left + w > vw - pad) left = anchor.left - w - pad;
if (left < pad) left = Math.min(Math.max(pad, anchor.left), Math.max(pad, vw - w - pad));
let top = anchor.top;
if (top + h > vh - pad) top = vh - h - pad;
if (top < pad) top = pad;
pop.style.left = Math.round(left) + 'px';
pop.style.top = Math.round(top) + 'px';
}
function clampPopover(pop) {
const pad = 8;
const maxLeft = Math.max(pad, window.innerWidth - pop.offsetWidth - pad);
const maxTop = Math.max(pad, window.innerHeight - pop.offsetHeight - pad);
pop.style.left = Math.min(Math.max(pad, pop.offsetLeft), maxLeft) + 'px';
pop.style.top = Math.min(Math.max(pad, pop.offsetTop), maxTop) + 'px';
}
/** Open (or close) the settings window for `tile`, beside its element `el`; `button` is the gear that asked. */
function toggleSettingsPopover(tile, el, button = null) {
const existing = document.querySelector(`.tile-settings[data-tile-id="${tile.id}"]`);
closeSettingsPopovers();
if (existing) return;
const man = registry.get(tile.module);
if (!man) return;
const { schema, groups: groupMeta } = tileSchema(tile, man);
const pop = document.createElement('div');
pop.className = 'tile-settings';
pop.dataset.tileId = tile.id;
pop.setAttribute('role', 'dialog');
pop.setAttribute('aria-label', `${tile.title || man.name} settings`);
// The gear that opened it wears the window's colour while it is open.
if (button) {
button.classList.add('is-open');
pop._button = button;
}
// A slim bar to move it by — nothing else. No title (the window sits beside
// the tile it belongs to) and no close button: a click anywhere else, or
// Escape, closes it.
const bar = document.createElement('div');
bar.className = 'tile-settings-bar';
bar.title = 'Drag to move';
const body = document.createElement('div');
body.className = 'tile-settings-body';
pop.append(bar, body);
const inputs = new Map();
const shownWhen = []; // fields that appear only while another has a given value
/* Display choices apply as they change and save with the layout — nothing
to confirm, so no Apply. Typing is debounced because applying means
remounting the module, which shouldn't happen per keystroke. */
const same = (a, b) => a === b
|| (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => v === b[i]));
let applyTimer = null;
const commit = (delay) => {
clearTimeout(applyTimer);
applyTimer = setTimeout(() => {
applyTimer = null;
// Only when something really moved: a text field fires `input` and then
// `change` on the way out, and a module shouldn't be torn down twice
// over for one edit. A reader answering undefined has nothing to say.
let changed = false;
for (const [key, read] of inputs) {
const value = read();
if (value === undefined || same(tile.settings[key], value)) continue;
tile.settings[key] = value;
changed = true;
}
if (!changed) return;
saveLayout();
remountTile(tile);
}, delay);
};
const live = (input, event = 'change', delay = 0) => {
input.addEventListener(event, () => commit(delay));
};
/** Choices a module only knows at runtime, from one of its own routes. */
const fetchOptions = async (route) => {
try {
const res = await fetchWithTimeout(`/api/modules/${encodeURIComponent(man.id)}${route}`, 8000);
const data = res.ok ? await res.json() : null;
return Array.isArray(data?.options) ? data.options : null;
} catch {
return null;
}
};
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
const optLabel = (opt) => String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
// Fields render in schema order; a field's `group` starts a labeled
// subsection that consecutive same-group fields share, and the groups map
// adds { collapsed, columns, help, toggle } — the same conventions as the
// admin page's config form.
let groupName = null;
let container = body;
let current = null; // the open group: { el, title, toggleKey }
for (const [key, spec] of Object.entries(schema)) {
const type = spec?.type || 'string';
const label = spec?.label || key;
const value = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
const g = spec?.group || '';
if (g !== groupName) {
groupName = g;
if (g) {
const meta = groupMeta[g] && typeof groupMeta[g] === 'object' ? groupMeta[g] : {};
const folds = meta.collapsed !== undefined;
const group = document.createElement(folds ? 'details' : 'div');
group.className = 'field-group';
if (folds) group.open = !meta.collapsed;
const title = document.createElement(folds ? 'summary' : 'div');
title.className = 'field-group-title';
const titleText = document.createElement('span');
titleText.className = 'field-group-name';
titleText.textContent = g;
title.appendChild(titleText);
group.appendChild(title);
const groupBody = document.createElement('div');
groupBody.className = 'field-group-body';
const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0);
if (cols > 1) {
groupBody.classList.add('is-columns');
groupBody.style.setProperty('--cols', String(cols));
}
if (meta.help) {
const help = document.createElement('div');
help.className = 'field-group-help';
help.textContent = meta.help;
groupBody.appendChild(help);
}
group.appendChild(groupBody);
body.appendChild(group);
pop.classList.add('has-groups');
container = groupBody;
current = { el: group, title, toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '' };
} else {
container = body;
current = null;
}
}
const isSwitch = type === 'boolean' || type === 'switch';
// A multiselect holds several controls, so it can't be a <label>: a click
// anywhere in one would flip the first control.
const field = document.createElement(type === 'multiselect' ? 'div' : 'label');
field.className = 'field' + (isSwitch ? ' check' : '');
let switchInput = null;
if (isSwitch) {
// A switch: on is shown/enabled, off is hidden/disabled — the label
// names what it turns on. A real checkbox stays underneath (keyboard,
// label clicks and `change` all keep working); the track is the look.
const text = document.createElement('span');
text.className = 'check-label';
text.textContent = label;
const toggle = document.createElement('span');
toggle.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = Boolean(value);
input.setAttribute('aria-label', label);
const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
field.append(text, toggle);
inputs.set(key, () => input.checked);
live(input);
switchInput = input;
} else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) {
const span = document.createElement('span');
span.textContent = label;
const select = document.createElement('select');
const saved = value === undefined || value === null ? '' : String(value);
const fill = (options) => {
const wanted = select.options.length ? select.value : saved;
select.innerHTML = '';
// What this tile saved stays choosable even when the live list has
// moved on (a source that is off right now, say).
if (saved && !options.some((o) => optValue(o) === saved)) {
options = [{ value: saved, label: `${saved} (saved)` }, ...options];
}
for (const opt of options) {
const o = document.createElement('option');
o.value = optValue(opt);
o.textContent = optLabel(opt);
select.appendChild(o);
}
select.value = options.some((o) => optValue(o) === wanted) ? wanted : saved;
};
fill(Array.isArray(spec.options) ? spec.options : []);
if (spec.optionsRoute) fetchOptions(spec.optionsRoute).then((opts) => { if (opts) fill(opts); });
field.append(span, select);
inputs.set(key, () => select.value);
live(select);
} else if (type === 'multiselect' && (Array.isArray(spec.options) || spec.optionsRoute)) {
// Several picks from a list, one switch each, stored as an array. With
// `allByDefault`, a tile that has never chosen shows every option on
// and saves nothing until someone flips one — so options that turn up
// later are on as well, until there is an explicit list.
field.classList.add('multiselect-field');
const span = document.createElement('span');
span.className = 'field-label';
span.textContent = label;
const list = document.createElement('div');
list.className = 'multiselect';
const cols = Math.min(4, Math.trunc(Number(spec.columns)) || 0);
if (cols > 1) {
list.classList.add('is-columns');
list.style.setProperty('--cols', String(cols));
}
const saved = Array.isArray(value) ? value.map(String) : null; // null: never chosen
let touched = false;
const chosen = new Set(saved || []);
const fill = (options) => {
for (const input of list.querySelectorAll('input')) {
if (input.checked) chosen.add(input.value);
else chosen.delete(input.value);
}
list.innerHTML = '';
const missing = [...chosen].filter((v) => !options.some((o) => optValue(o) === v));
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
for (const opt of all) {
const row = document.createElement('label');
row.className = 'multiselect-row';
const text = document.createElement('span');
text.className = 'check-label';
text.textContent = optLabel(opt);
const toggle = document.createElement('span');
toggle.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.value = optValue(opt);
input.checked = saved === null && !touched && spec.allByDefault ? true : chosen.has(input.value);
if (input.checked) chosen.add(input.value);
const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
row.append(text, toggle);
input.addEventListener('change', () => {
touched = true;
commit(0);
});
list.appendChild(row);
}
if (!all.length) {
const none = document.createElement('div');
none.className = 'multiselect-empty';
none.textContent = 'Nothing to choose from yet.';
list.appendChild(none);
}
};
fill(Array.isArray(spec.options) ? spec.options : []);
if (spec.optionsRoute) fetchOptions(spec.optionsRoute).then((opts) => { if (opts) fill(opts); });
field.append(span, list);
inputs.set(key, () => (saved === null && !touched
? undefined
: [...list.querySelectorAll('input:checked')].map((input) => input.value)));
} else if (type === 'color') {
// A swatch picker; the value is always a #rrggbb string.
field.classList.add('color-field');
const span = document.createElement('span');
span.textContent = label;
const input = document.createElement('input');
input.type = 'color';
input.value = colorHex(value, colorHex(spec?.default, '#2ee59a'));
field.append(span, input);
inputs.set(key, () => input.value);
live(input, 'input', 150); // dragging the picker fires continuously
} else if (type === 'template') {
// Text with placeholders, built by dragging (or clicking) the variables
// the module offers into it — nobody should have to remember the names.
field.classList.add('template-field');
const span = document.createElement('span');
span.textContent = label;
const input = document.createElement('input');
input.type = 'text';
input.spellcheck = false;
input.value = value === undefined || value === null ? '' : String(value);
const vars = document.createElement('div');
vars.className = 'template-vars';
for (const v of Array.isArray(spec.variables) ? spec.variables : []) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'template-var';
chip.draggable = true;
const token = optValue(v);
chip.textContent = optLabel(v);
chip.title = `Drag or click to insert ${token}`;
chip.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', token);
e.dataTransfer.effectAllowed = 'copy';
});
chip.addEventListener('click', (e) => {
e.preventDefault();
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? start;
input.setRangeText(token, start, end, 'end');
input.focus();
commit(0);
});
vars.appendChild(chip);
}
field.append(span, input, vars);
inputs.set(key, () => input.value);
live(input, 'input', 400);
live(input, 'change');
} else {
const span = document.createElement('span');
span.textContent = label;
const input = document.createElement('input');
input.type = type === 'number' ? 'number' : (type === 'password' ? 'password' : 'text');
input.value = value === undefined || value === null ? '' : String(value);
field.append(span, input);
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
live(input, 'input', 400); // mid-word is not the moment to remount
live(input, 'change'); // …but blur or Enter is
}
if (spec?.help) {
const help = document.createElement('small');
help.className = 'field-help';
help.textContent = spec.help;
field.appendChild(help);
}
if (typeof spec?.showWhen === 'string' && spec.showWhen) shownWhen.push({ field, key: spec.showWhen });
if (current && switchInput && key === current.toggleKey) {
// The group's on/off lives in its title row, never in the body, and a
// click on it must not fold a <details>.
const grp = current;
field.classList.add('group-toggle');
field.addEventListener('click', (e) => e.stopPropagation());
const sync = () => grp.el.classList.toggle('is-off', !switchInput.checked);
switchInput.addEventListener('change', sync);
sync();
grp.title.appendChild(field);
} else {
container.appendChild(field);
}
}
/* The tint sits on its own at the foot of the window, under a hairline —
it is the shell's setting, not one of the module's. Unlike those, a
change needs no remount: nothing but the tile's own chrome reads it. */
{
const field = document.createElement('div');
field.className = 'field color-field tint-field'; // the swatch reads like a module's colour field
const text = document.createElement('span');
text.textContent = 'Tile tint';
const input = document.createElement('input');
input.type = 'color';
input.setAttribute('aria-label', 'Tile tint');
const clear = document.createElement('button');
clear.type = 'button';
clear.className = 'tint-clear';
clear.textContent = 'Clear';
clear.title = 'No tint';
const paint = () => {
const hex = colorHex(tile.settings[TINT_KEY], '');
// With no tint the swatch opens on the panel colour, so the picker
// starts somewhere sane rather than at black.
input.value = hex || colorHex(getComputedStyle(document.documentElement).getPropertyValue('--panel'), '#1a1d21');
clear.hidden = !hex;
};
const set = (hex) => {
if (hex) tile.settings[TINT_KEY] = hex;
else delete tile.settings[TINT_KEY];
applyTint(tile);
paint();
saveLayout();
};
input.addEventListener('input', () => set(colorHex(input.value, '')));
clear.addEventListener('click', () => set(''));
paint();
field.append(text, clear, input);
body.appendChild(field);
}
// "Short day" appears once "Date" is on: a field with showWhen:"K" follows
// K's switch, live — the same rule the admin page applies.
if (shownWhen.length) {
const refresh = () => {
for (const rule of shownWhen) {
const read = inputs.get(rule.key);
rule.field.hidden = read ? !read() : false;
}
};
refresh();
body.addEventListener('change', refresh);
body.addEventListener('input', refresh);
}
// Nothing to press: click away, press Escape, or the gear again.
// (The gear's own click stops propagating, so it toggles rather than
// closing and reopening.)
const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); };
const closeOnEscape = (e) => { if (e.key === 'Escape') closeSettingsPopovers(); };
const keepInView = () => clampPopover(pop);
pop._cleanup = () => {
document.removeEventListener('click', closeOnClick);
document.removeEventListener('keydown', closeOnEscape);
window.removeEventListener('resize', keepInView);
};
document.addEventListener('click', closeOnClick);
document.addEventListener('keydown', closeOnEscape);
window.addEventListener('resize', keepInView);
// Moved by its bar.
bar.addEventListener('pointerdown', (e) => {
if (e.button !== 0 && e.pointerType === 'mouse') return;
e.preventDefault();
bar.setPointerCapture(e.pointerId);
const dx = e.clientX - pop.offsetLeft;
const dy = e.clientY - pop.offsetTop;
const onMove = (ev) => {
pop.style.left = (ev.clientX - dx) + 'px';
pop.style.top = (ev.clientY - dy) + 'px';
clampPopover(pop);
};
const onUp = () => {
bar.removeEventListener('pointermove', onMove);
bar.removeEventListener('pointerup', onUp);
bar.removeEventListener('pointercancel', onUp);
};
bar.addEventListener('pointermove', onMove);
bar.addEventListener('pointerup', onUp);
bar.addEventListener('pointercancel', onUp);
});
pop.addEventListener('pointerdown', (e) => e.stopPropagation());
document.body.appendChild(pop);
placePopover(pop, el.getBoundingClientRect());
}
/** Tear a tile's module down and start it again (settings changed). */
function remountTile(tile) {
const entry = tileEls.get(tile.id);
if (!entry) return;
unmountModule(tile);
entry.body.innerHTML = '';
entry.dot.className = 'dot';
mountModule(tile);
}
/* ── layout apply / reset ───────────────────────────────────────────── */
function renderLayout(layout) {
// Callers pass a whole layout; an array on its own is still understood so
// "reset" can simply hand over no tiles.
const list = Array.isArray(layout) ? layout : (layout?.tiles || []);
clearAllTiles();
// Density first: the tiles are clamped and placed against it.
if (!Array.isArray(layout)) setGridCols(layout?.gridCols ?? GRID_DEFAULT, { save: false });
tiles = list.map(sanitizeTile);
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile);
scheduleOfferedRecheck();
refreshEmptyState();
rebuildDividers();
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
}
/* ── menus ──────────────────────────────────────────────────────────── */
/* What the + picker shows, as a string: repainting a menu the user is
already pointing at is only worth it when something in it changed. */
function addMenuSignature() {
return JSON.stringify([...registry.values()].map((m) => [m.id, m.name, m.description, m.tiles]));
}
wireDropdown('add-btn', 'add-menu', async (menu, stillOpen) => {
// Painted from the list already in hand, then refreshed: a module's tile
// list is live data, and Admin may have enabled or disabled something.
const shown = addMenuSignature();
renderAddMenu(menu);
if (await loadRegistry() && stillOpen() && addMenuSignature() !== shown) renderAddMenu(menu);
});
function renderAddMenu(menu) {
menu.innerHTML = '';
const manifests = [...registry.values()];
if (!manifests.length) {
const note = document.createElement('div');
note.className = 'menu-note';
note.textContent = 'No modules are enabled. Enable some in Admin.';
menu.appendChild(note);
return;
}
for (const man of manifests) {
const entries = Array.isArray(man.tiles) ? man.tiles : [];
if (entries.length) {
// A multi-tile module: its name becomes a group heading and each of
// its tiles is added individually.
const title = document.createElement('div');
title.className = 'menu-title';
title.textContent = man.name;
menu.appendChild(title);
for (const entry of entries) {
// An entry that allows one tile at most is offered greyed out once
// that tile exists — a second Send box would only confuse.
const placed = entry.single && tiles.some((t) => t.module === man.id && t.variant === String(entry.id));
// Descriptions ride along as tooltips: the list stays a list.
menu.appendChild(menuItem(entry.name, () => {
menu.hidden = true;
addTile(man.id, entry);
}, { disabled: placed, title: placed ? 'Already on this dashboard' : (entry.description || '') }));
}
} else {
menu.appendChild(menuItem(man.name, () => {
menu.hidden = true;
addTile(man.id);
}, { title: man.description || '' }));
}
}
}
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
menu.innerHTML = '';
// Density first: the one thing in here that is not a layout but the board
// the layouts are arranged on.
menu.appendChild(gridStepper());
const stepperDivider = document.createElement('div');
stepperDivider.className = 'menu-divider';
menu.appendChild(stepperDivider);
menu.appendChild(menuItem('Save as named layout…', async () => {
menu.hidden = true;
if (!tiles.length) {
toast('Nothing to save — this dashboard is empty', true);
return;
}
const name = await promptModal({
title: 'Save this layout to the server',
placeholder: 'e.g. FOH booth, Video world',
okLabel: 'Save',
});
if (!name) return;
try {
// Saving over a name is asked about first — and the copy it replaces
// is kept one back on the server, so a yes is not the end of it either.
const existing = await fetch('/api/layouts')
.then((r) => (r.ok ? r.json() : { layouts: [] }))
.then((b) => (b.layouts || []).find((l) => l.name === name))
.catch(() => null);
if (existing) {
const when = existing.updated ? new Date(existing.updated).toLocaleString() : 'earlier';
const go = await confirmModal({
title: `Replace “${name}”?`,
body: `It was saved ${when} with ${existing.tiles} tile${existing.tiles === 1 ? '' : 's'}. The copy it replaces is kept as its previous version.`,
okLabel: 'Replace',
});
if (!go) return;
}
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: { autoHideHeads, gridCols: COLS, tiles } }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
toast(body.replaced
? `Replaced “${name}” — the earlier copy is kept as its previous version`
: `Saved “${name}” — any browser can load it now`);
} catch (e) {
toast(e.message, true);
}
}, { sub: 'Share this arrangement with other browsers' }));
const divider = document.createElement('div');
divider.className = 'menu-divider';
menu.appendChild(divider);
const reset = menuItem('Reset layout', () => {
menu.hidden = true;
renderLayout([]);
saveLayout();
toast('Layout cleared');
}, { danger: true, sub: 'Remove every tile from this browser' });
menu.appendChild(reset);
// Named layouts stored on the server slot in above Reset once they
// arrive; until then (or if they never do) the menu is already usable.
let layouts = [];
try {
const res = await fetchWithTimeout('/api/layouts', 8000);
if (res.ok) layouts = (await res.json()).layouts || [];
} catch { /* server briefly away — the menu just shows none */ }
if (!layouts.length || !stillOpen()) return;
const block = document.createDocumentFragment();
const title = document.createElement('div');
title.className = 'menu-title';
title.textContent = 'Load from server';
block.appendChild(title);
for (const l of layouts) {
block.appendChild(menuItem(l.name, async () => {
menu.hidden = true;
try {
const res = await fetch('/api/layouts/' + encodeURIComponent(l.name));
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
// Loading copies the layout into this browser's own state — it
// does not live-link browsers together.
let outgoing = null;
try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ }
const gridBefore = COLS;
renderLayout(body.layout);
saveLayout();
try { if (outgoing) localStorage.setItem(LS_LAYOUT_PREVIOUS, outgoing); } catch { /* fine */ }
const unshown = unshownTileNames();
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
const note = unshown.length
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
: '';
toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
ms: unshown.length ? 10000 : 7000,
action: outgoing ? {
label: 'Undo',
onClick() {
try {
renderLayout(JSON.parse(outgoing));
saveLayout();
toast('Back to the arrangement you had');
} catch {
toast('Could not go back', true);
}
},
} : null,
});
} catch (e) {
toast(e.message, true);
}
}, { sub: `${l.tiles} tile${l.tiles === 1 ? '' : 's'}` }));
}
block.appendChild(divider.cloneNode());
menu.insertBefore(block, reset);
});
2026-09-17 12:04:59 -04:00
/* ── menu bar show/hide (its own notch, at the top-left) ────────────── */
const topbar = document.getElementById('topbar');
const topbarNotch = document.getElementById('topbar-notch');
const LS_MENU = 'proddash:menuHidden';
/** Keep the notch glued to the menubar's bottom edge, whatever its height. */
const topbarSizer = new ResizeObserver(() => {
document.documentElement.style.setProperty('--topbar-h', topbar.offsetHeight + 'px');
});
topbarSizer.observe(topbar);
function setMenuHidden(hidden, { persist = true } = {}) {
document.body.classList.toggle('menu-hidden', hidden);
if (persist) {
try { localStorage.setItem(LS_MENU, hidden ? '1' : '0'); } catch { /* fine */ }
}
}
topbarNotch.addEventListener('click', () => {
setMenuHidden(!document.body.classList.contains('menu-hidden'));
});
try {
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
} catch { /* fine */ }
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
to the full width, and every column runs to the bottom of the screen: the
bottom-most tile of each column stretches to end exactly there. A small
tile (the clock) is never stretched into a ribbon — with a tile above it,
it drops to the bottom edge and the tile above grows into the gap; alone
in its columns, it stays exactly as it is. When nothing extends below the
viewport, scrolling is disabled. All of it is visual-only — the saved
layout is untouched and everything reverts on exit. */
const fsBtn = document.getElementById('fs-btn');
/** Tiles this short (the clock) are moved, never stretched, in fullscreen. */
const FS_SMALL_ROWS = 2;
let menuHiddenBeforeFs = false;
function colsOverlap(a, b) {
return a.x < b.x + b.w && b.x < a.x + a.w;
}
function fsSmall(tile) {
return tile.h <= FS_SMALL_ROWS;
}
function fsAloneInColumns(tile) {
return !tiles.some((t) => t !== tile && colsOverlap(t, tile));
}
function fsBottomMost(tile) {
return !tiles.some((t) => t !== tile && colsOverlap(t, tile) && t.y >= tile.y + tile.h);
}
/**
* Fullscreen row placement for every tile, given R rows on screen:
* { id -> { y, h } }. Only bottom-most tiles change (stretch, or drop when
* small), plus the tile directly above a dropped small one, which grows
* into the space it left — stopping at anything else below it.
*/
function fullscreenRows(R) {
const rows = new Map(tiles.map((t) => [t.id, { y: t.y, h: t.h }]));
const dropped = [];
for (const tile of tiles) {
if (!fsBottomMost(tile)) continue;
if (!fsSmall(tile)) {
rows.get(tile.id).h = R - tile.y;
} else if (!fsAloneInColumns(tile) && R - tile.h > tile.y) {
rows.get(tile.id).y = R - tile.h;
dropped.push(tile);
}
}
for (const small of dropped) {
const gapTop = rows.get(small.id).y;
for (const above of tiles) {
if (above === small || fsSmall(above) || !colsOverlap(above, small)) continue;
if (above.y + above.h !== small.y) continue; // only a tile touching it
let limit = gapTop;
for (const other of tiles) {
if (other === above || other === small || !colsOverlap(above, other)) continue;
const oy = rows.get(other.id).y;
if (oy >= above.y + above.h) limit = Math.min(limit, oy);
}
rows.get(above.id).h = Math.max(above.h, limit - above.y);
}
}
return rows;
}
function applyFullscreenFit() {
if (!document.body.classList.contains('fullscreen') || !tiles.length) return;
const m = cellMetrics();
const baseCellH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cell-h')) || 72;
const wrapH = gridwrap.clientHeight;
const maxCol = Math.max(1, ...tiles.map((t) => t.x + t.w));
const maxRow = Math.max(1, ...tiles.map((t) => t.y + t.h));
// x: the occupied columns stretch proportionally across the full width
grid.style.gridTemplateColumns = `repeat(${maxCol}, minmax(0, 1fr))`;
const layoutH = maxRow * (baseCellH + m.gap) - m.gap;
if (layoutH > wrapH) {
// deeper than the screen: keep natural rows and scrolling
grid.style.gridAutoRows = '';
gridwrap.classList.remove('no-scroll');
for (const tile of tiles) applyRect(tile);
return;
}
// nothing below the fold: lock scrolling and make R rows fill the height exactly
gridwrap.classList.add('no-scroll');
const R = Math.max(maxRow, Math.floor((wrapH + m.gap) / (baseCellH + m.gap)));
const cellH = (wrapH - (R - 1) * m.gap) / R;
grid.style.gridAutoRows = cellH + 'px';
const rows = fullscreenRows(R);
for (const tile of tiles) {
const entry = tileEls.get(tile.id);
if (!entry) continue;
const r = rows.get(tile.id);
entry.el.style.gridColumn = `${tile.x + 1} / span ${tile.w}`;
entry.el.style.gridRow = `${r.y + 1} / span ${r.h}`;
try { entry.instance?.onResize?.(tile.w, r.h); } catch { /* module's problem, not the shell's */ }
}
}
function clearFullscreenFit() {
// Back to this browser's density, not the stylesheet's twelve.
grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`;
grid.style.gridAutoRows = '';
gridwrap.classList.remove('no-scroll');
for (const tile of tiles) {
applyRect(tile);
notifyResize(tile);
}
}
autoHideBtn?.addEventListener('click', () => {
setAutoHideHeads(!autoHideHeads);
toast(autoHideHeads
? 'Title bars hide until you point at a tile\u2019s top edge'
: 'Title bars stay put');
});
fsBtn.addEventListener('click', () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen?.().catch(() => {});
} else {
document.exitFullscreen?.().catch(() => {});
}
});
document.addEventListener('fullscreenchange', () => {
const active = Boolean(document.fullscreenElement);
document.body.classList.toggle('fullscreen', active);
if (active) {
closeSettingsPopovers();
applyAllHeadStates(); // fullscreen has its own rule for the bars
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
// let the collapsed menubar's space settle before measuring
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
} else {
setMenuHidden(menuHiddenBeforeFs, { persist: false });
clearFullscreenFit();
applyAllHeadStates();
}
});
// The grid area's size settles late: the browser animates into fullscreen,
// then the menubar collapse animates. Re-fit whenever its size changes.
let fsRefit = 0;
new ResizeObserver(() => {
if (!document.body.classList.contains('fullscreen')) return;
cancelAnimationFrame(fsRefit);
fsRefit = requestAnimationFrame(applyFullscreenFit);
}).observe(gridwrap);
/* ── live updates from the admin page ───────────────────────────────── */
/* The server broadcasts `modules-changed` on /api/events whenever admin
config or enabled flags change. Tiles of a changed module get
onConfigChange(cfg) if they implement it, otherwise a clean remount. */
function subscribeEvents() {
2026-09-15 11:02:36 -04:00
// Rides on the page's one stream connection (see sseSubscribe) as `shell:`.
sseSubscribe('/api/events', {
events: {
'modules-changed': () => handleModulesChanged().then(reconcileOfferedTiles),
2026-09-15 11:02:36 -04:00
theme: handleThemeEvent,
},
});
}
async function handleModulesChanged() {
const before = registry;
if (!(await loadRegistry())) return;
for (const tile of [...tiles]) {
const oldMan = before.get(tile.module);
const newMan = registry.get(tile.module);
if (!newMan !== !oldMan) {
// enabled or disabled: rebuild the tile body (shows the module, or
// an "unavailable" note)
remountTile(tile);
continue;
}
if (!newMan) continue;
if (JSON.stringify(oldMan.config || {}) !== JSON.stringify(newMan.config || {})) {
const entry = tileEls.get(tile.id);
if (typeof entry?.instance?.onConfigChange === 'function') {
try {
entry.instance.onConfigChange({ ...newMan.config });
} catch {
remountTile(tile);
}
} else {
remountTile(tile);
}
}
}
}
/* ── 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() {
const ok = await loadRegistry();
if (!ok) {
toast('ProdDash server unreachable — retrying…', true);
setTimeout(boot, 4000);
return;
}
renderLayout(loadLayoutFromStorage());
subscribeEvents();
// Entries a module builds at runtime (a timer per discovered source) can
// land a beat after the first registry read; look once more shortly after
// boot so a tile hidden only by that timing appears without a nudge.
setTimeout(async () => { if (await loadRegistry()) reconcileOfferedTiles(); }, 5000);
}
boot();
2026-08-30 15:21:05 +00:00
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}