/* 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 : {}, 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 = ''; const STEP_PLUS_SVG = ''; /** The Layouts menu's density row — Grid Size − 12 + — 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'; label.textContent = 'Grid Size'; 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 = String(COLS); 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 ──────────────────────────────────────── */ /* 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) { 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 = ''; const PIN_SVG = ''; const GEAR_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'; /* 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; el.classList.remove('head-peek'); }); 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) => { 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 || {}); 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 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); } /* ── one stream per page ─────────────────────────────────────────────── */ /* Browsers allow about six HTTP/1.1 connections to one host, and an open 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=, 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. */ 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/` → `:`, `/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 `|` 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 = {}) { 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 }; 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() { 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 * 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 ──────────────────────────────────────── */ /** `` 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