diff --git a/config/proddash.json b/config/proddash.json
new file mode 100644
index 0000000..869d4c8
--- /dev/null
+++ b/config/proddash.json
@@ -0,0 +1,4 @@
+{
+ "port": 24500,
+ "adminPasscode": ""
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..69afd10
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+ ProdDash
+
+
+
+
+
+ ProdDash
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Admin
+
+
+
+
+
+
No tiles yet — use “+ Add tile” to build this dashboard.
+ Every browser keeps its own layout.
+
+
+
+
+
+
+
+
+
+
diff --git a/public/shell.js b/public/shell.js
new file mode 100644
index 0000000..ada4c88
--- /dev/null
+++ b/public/shell.js
@@ -0,0 +1,605 @@
+/* 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. */
+
+const COLS = 12;
+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) {
+ toastEl.textContent = msg;
+ toastEl.classList.toggle('error', isError);
+ toastEl.hidden = false;
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200);
+}
+
+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 });
+ });
+}
+
+/* ── dropdown menus ─────────────────────────────────────────────────── */
+
+function wireDropdown(btnId, menuId, rebuild) {
+ const btn = document.getElementById(btnId);
+ const menu = document.getElementById(menuId);
+ btn.addEventListener('click', async () => {
+ if (menu.hidden) await rebuild(menu);
+ menu.hidden = !menu.hidden;
+ });
+ 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 } = {}) {
+ const item = document.createElement('button');
+ item.className = 'menu-item' + (danger ? ' danger' : '');
+ 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 ────────────────────────────────────────────────── */
+
+/* Filled from GET /api/modules once the module system is up (milestone 2).
+ Until then — or if the fetch fails — the shell offers a built-in
+ placeholder tile so the grid itself is usable and testable. */
+
+const PLACEHOLDER_MANIFEST = {
+ id: '__placeholder',
+ name: 'Placeholder',
+ description: 'An empty tile for trying out the grid',
+ defaultSize: { w: 3, h: 2 },
+ minSize: { w: 1, h: 1 },
+ builtin: true,
+};
+
+let registry = new Map(); // module id -> manifest
+
+async function loadRegistry() {
+ registry = new Map();
+ try {
+ const res = await fetch('/api/modules');
+ if (!res.ok) throw new Error('modules ' + res.status);
+ const body = await res.json();
+ for (const m of body.modules || []) registry.set(m.id, m);
+ } catch {
+ /* no module API yet (or server just restarted) — placeholder only */
+ }
+ if (!registry.size) registry.set(PLACEHOLDER_MANIFEST.id, PLACEHOLDER_MANIFEST);
+}
+
+/* ── 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 }
+
+function saveLayout() {
+ try {
+ localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, tiles }));
+ } catch { /* private mode / full — layout just won't persist */ }
+}
+
+function loadLayoutFromStorage() {
+ try {
+ const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null');
+ if (raw && Array.isArray(raw.tiles)) return raw.tiles;
+ } catch { /* corrupted — start fresh */ }
+ return [];
+}
+
+function sanitizeTile(t) {
+ const w = Math.max(1, Math.min(COLS, Number(t.w) || 3));
+ return {
+ 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 : {},
+ };
+}
+
+function newId() {
+ return 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
+}
+
+/* ── grid geometry & collision ──────────────────────────────────────── */
+
+function cellMetrics() {
+ const style = getComputedStyle(grid);
+ const gap = parseFloat(style.columnGap) || 10;
+ 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 isFree(rect, excludeId) {
+ if (rect.x < 0 || rect.y < 0 || rect.x + rect.w > COLS) return false;
+ return !tiles.some((t) => t.id !== excludeId && overlaps(rect, t));
+}
+
+/** 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 man = registry.get(tile.module);
+ return {
+ w: Math.max(1, man?.minSize?.w || 1),
+ h: Math.max(1, man?.minSize?.h || 1),
+ };
+}
+
+/* ── tile DOM ───────────────────────────────────────────────────────── */
+
+const GEAR_SVG = '';
+
+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;
+
+ 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 ? man.name : tile.module + ' (not installed)';
+
+ head.append(dot, title);
+
+ const hasSettings = man && man.instanceSchema && Object.keys(man.instanceSchema).length;
+ if (hasSettings) {
+ const gear = document.createElement('button');
+ gear.className = 'tile-btn';
+ gear.title = 'Tile settings (this tile only)';
+ gear.innerHTML = GEAR_SVG;
+ gear.addEventListener('click', (e) => {
+ e.stopPropagation();
+ toggleSettingsPopover(tile, el);
+ });
+ head.appendChild(gear);
+ }
+
+ const close = document.createElement('button');
+ close.className = 'tile-btn close';
+ close.title = 'Remove this tile';
+ close.textContent = '✕';
+ close.addEventListener('click', (e) => {
+ e.stopPropagation();
+ removeTile(tile.id);
+ });
+ head.appendChild(close);
+
+ const body = document.createElement('div');
+ body.className = 'tile-body';
+
+ const resize = document.createElement('div');
+ resize.className = 'tile-resize';
+ resize.title = 'Drag to resize';
+
+ el.append(head, body, resize);
+ grid.appendChild(el);
+
+ const entry = { el, body, dot, titleEl: title, instance: null };
+ tileEls.set(tile.id, entry);
+ applyRect(tile);
+
+ wireDrag(tile, el, head);
+ wireResize(tile, el, resize);
+
+ 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')) return;
+ if (ev.button !== 0 && ev.pointerType === 'mouse') 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');
+ }
+ 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');
+ if (moved) saveLayout();
+ };
+ handle.addEventListener('pointermove', onMove);
+ handle.addEventListener('pointerup', onUp);
+ handle.addEventListener('pointercancel', onUp);
+ });
+}
+
+/* ── drag to resize ─────────────────────────────────────────────────── */
+
+function wireResize(tile, el, handle) {
+ handle.addEventListener('pointerdown', (ev) => {
+ if (ev.button !== 0 && ev.pointerType === 'mouse') return;
+ ev.preventDefault();
+ ev.stopPropagation();
+ handle.setPointerCapture(ev.pointerId);
+ const m = cellMetrics();
+ const sx = ev.clientX;
+ const sy = ev.clientY;
+ const startW = tile.w;
+ const startH = tile.h;
+ const min = minSizeOf(tile);
+ el.classList.add('resizing');
+ 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));
+ const 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.w !== tile.w || cand.h !== tile.h) && isFree(cand, tile.id)) {
+ 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');
+ if (changed) saveLayout();
+ };
+ 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 */ }
+}
+
+/* ── add / remove tiles ─────────────────────────────────────────────── */
+
+function addTile(moduleId) {
+ const man = registry.get(moduleId);
+ const size = {
+ w: Math.max(1, Math.min(COLS, man?.defaultSize?.w || 4)),
+ h: Math.max(1, man?.defaultSize?.h || 3),
+ };
+ const spot = findSpot(size.w, size.h);
+ const settings = {};
+ for (const [key, spec] of Object.entries(man?.instanceSchema || {})) {
+ if (spec && 'default' in spec) settings[key] = spec.default;
+ }
+ const tile = { id: newId(), module: moduleId, ...spot, ...size, settings };
+ tiles.push(tile);
+ buildTile(tile);
+ refreshEmptyState();
+ saveLayout();
+}
+
+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();
+}
+
+function clearAllTiles() {
+ for (const tile of tiles) {
+ unmountModule(tile);
+ tileEls.get(tile.id)?.el.remove();
+ }
+ tiles = [];
+ tileEls.clear();
+ refreshEmptyState();
+}
+
+/* ── module mounting ────────────────────────────────────────────────── */
+
+/* Milestone 2 turns this into the real module loader (dynamic import of each
+ module's client.js plus the moduleApi). For now only the built-in
+ placeholder renders. */
+
+function mountModule(tile) {
+ const entry = tileEls.get(tile.id);
+ if (!entry) return;
+ const man = registry.get(tile.module);
+ if (!man) {
+ entry.body.innerHTML = 'Module “' + tile.module + '” is not installed.
';
+ entry.dot.className = 'dot error';
+ return;
+ }
+ if (man.builtin) {
+ const msg = document.createElement('div');
+ msg.className = 'tile-msg';
+ msg.textContent = 'Placeholder tile — drag me by the header, resize me by the corner.';
+ entry.body.appendChild(msg);
+ entry.dot.className = 'dot ok';
+ }
+}
+
+function unmountModule(tile) {
+ const entry = tileEls.get(tile.id);
+ try {
+ entry?.instance?.stop?.();
+ } catch { /* stopping is best-effort */ }
+ if (entry) entry.instance = null;
+}
+
+/* ── per-tile settings popover ──────────────────────────────────────── */
+
+function closeSettingsPopovers() {
+ document.querySelectorAll('.tile-settings').forEach((p) => p.remove());
+}
+
+function toggleSettingsPopover(tile, el) {
+ const existing = el.querySelector('.tile-settings');
+ closeSettingsPopovers();
+ if (existing) return;
+ const man = registry.get(tile.module);
+ if (!man || !man.instanceSchema) return;
+
+ const pop = document.createElement('div');
+ pop.className = 'tile-settings';
+ const inputs = new Map();
+
+ for (const [key, spec] of Object.entries(man.instanceSchema)) {
+ const type = spec?.type || 'string';
+ const label = spec?.label || key;
+ const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
+
+ const field = document.createElement('label');
+ field.className = 'field' + (type === 'boolean' ? ' check' : '');
+
+ if (type === 'boolean') {
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ input.checked = Boolean(current);
+ field.append(input, document.createTextNode(label));
+ inputs.set(key, () => input.checked);
+ } else if (type === 'select' && Array.isArray(spec.options)) {
+ const span = document.createElement('span');
+ span.textContent = label;
+ const select = document.createElement('select');
+ for (const opt of spec.options) {
+ const o = document.createElement('option');
+ o.value = String(typeof opt === 'object' ? opt.value : opt);
+ o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
+ select.appendChild(o);
+ }
+ select.value = String(current ?? '');
+ field.append(span, select);
+ inputs.set(key, () => select.value);
+ } 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 = current === undefined || current === null ? '' : String(current);
+ field.append(span, input);
+ inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
+ }
+ pop.appendChild(field);
+ }
+
+ const actions = document.createElement('div');
+ actions.className = 'actions';
+ actions.style.display = 'flex';
+ actions.style.justifyContent = 'flex-end';
+ actions.style.gap = '8px';
+ const cancel = document.createElement('button');
+ cancel.className = 'btn';
+ cancel.textContent = 'Cancel';
+ cancel.addEventListener('click', () => pop.remove());
+ const apply = document.createElement('button');
+ apply.className = 'btn primary';
+ apply.textContent = 'Apply';
+ apply.addEventListener('click', () => {
+ for (const [key, read] of inputs) tile.settings[key] = read();
+ pop.remove();
+ saveLayout();
+ remountTile(tile);
+ });
+ actions.append(cancel, apply);
+ pop.appendChild(actions);
+
+ pop.addEventListener('pointerdown', (e) => e.stopPropagation());
+ el.appendChild(pop);
+}
+
+/** 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(list) {
+ clearAllTiles();
+ tiles = list.map(sanitizeTile);
+ for (const tile of tiles) buildTile(tile);
+ refreshEmptyState();
+}
+
+/* ── menus ──────────────────────────────────────────────────────────── */
+
+wireDropdown('add-btn', 'add-menu', async (menu) => {
+ await loadRegistry();
+ 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) {
+ menu.appendChild(menuItem(man.name, () => {
+ menu.hidden = true;
+ addTile(man.id);
+ }, { sub: man.description || '' }));
+ }
+});
+
+wireDropdown('layout-btn', 'layout-menu', async (menu) => {
+ menu.innerHTML = '';
+ menu.appendChild(menuItem('Reset layout', () => {
+ menu.hidden = true;
+ renderLayout([]);
+ saveLayout();
+ toast('Layout cleared');
+ }, { danger: true, sub: 'Remove every tile from this browser' }));
+});
+
+/* ── boot ───────────────────────────────────────────────────────────── */
+
+async function boot() {
+ await loadRegistry();
+ renderLayout(loadLayoutFromStorage());
+}
+
+boot();
diff --git a/public/style.css b/public/style.css
new file mode 100644
index 0000000..cb4f1e7
--- /dev/null
+++ b/public/style.css
@@ -0,0 +1,436 @@
+/* ProdDash shell — dark, high-contrast, production-booth friendly.
+ Visual language carried over from ProdCom Listener (same variable names, so
+ module CSS written against these looks native everywhere). Modules must
+ style with these variables only — no hardcoded colors. */
+
+:root {
+ --bg: #0b0e12;
+ --panel: #131920;
+ --border: #232b35;
+ --text: #e6edf3;
+ --muted: #8b98a5;
+ --accent: #2ee59a;
+ --danger: #f4433c;
+ --warn: #f0b429;
+ /* grid geometry (shell.js reads these) */
+ --cell-h: 72px;
+ --gap: 10px;
+}
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+html, body { height: 100%; }
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+button { font-family: inherit; }
+
+/* ── topbar ────────────────────────────────────────────────────────── */
+
+#topbar {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ background: var(--panel);
+ border-bottom: 1px solid var(--border);
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+#brand {
+ font-weight: 800;
+ font-size: 15px;
+ letter-spacing: 0.02em;
+ margin-right: 6px;
+}
+#brand .accent { color: var(--accent); }
+
+#topbar .spacer { flex: 1; }
+
+.tb-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 6px 10px;
+ font-size: 13px;
+ cursor: pointer;
+ text-decoration: none;
+}
+.tb-btn:hover { border-color: var(--muted); }
+.tb-btn:active { background: var(--border); }
+
+/* ── dropdown menus ────────────────────────────────────────────────── */
+
+.dropdown { position: relative; }
+
+.menu {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ z-index: 50;
+ min-width: 210px;
+ max-height: 70vh;
+ overflow-y: auto;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 5px;
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
+}
+.menu[hidden] { display: none; }
+
+.menu-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 8px 10px;
+ background: none;
+ border: none;
+ border-radius: 6px;
+ color: var(--text);
+ font-size: 13px;
+ text-align: left;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.menu-item:hover { background: var(--border); }
+.menu-item .sub {
+ display: block;
+ color: var(--muted);
+ font-size: 11px;
+ margin-top: 2px;
+ white-space: normal;
+}
+.menu-item.danger { color: var(--danger); }
+.menu-note {
+ padding: 8px 10px;
+ color: var(--muted);
+ font-size: 12px;
+ white-space: normal;
+}
+.menu-divider { height: 1px; background: var(--border); margin: 5px 4px; }
+.menu-title {
+ padding: 6px 10px 3px;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+/* ── grid ──────────────────────────────────────────────────────────── */
+
+#gridwrap {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: var(--gap);
+ overscroll-behavior: contain;
+}
+
+#grid {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+ grid-auto-rows: var(--cell-h);
+ gap: var(--gap);
+ /* Always leave room to drop/grow a tile below the last row. */
+ padding-bottom: calc(3 * (var(--cell-h) + var(--gap)));
+}
+
+#empty-state {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding-top: 12vh;
+ color: var(--muted);
+ font-size: 14px;
+ pointer-events: none;
+}
+#empty-state[hidden] { display: none; }
+
+/* ── tiles ─────────────────────────────────────────────────────────── */
+
+.tile {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.tile.dragging,
+.tile.resizing {
+ z-index: 40;
+ border-color: var(--muted);
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
+ opacity: 0.92;
+}
+
+.tile-head {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 8px;
+ background: rgba(255, 255, 255, 0.03);
+ border-bottom: 1px solid var(--border);
+ cursor: grab;
+ user-select: none;
+ -webkit-user-select: none;
+ touch-action: none;
+}
+.tile.dragging .tile-head { cursor: grabbing; }
+
+.tile-head .dot {
+ flex: none;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--muted);
+ transition: background 0.3s;
+}
+.tile-head .dot.ok { background: var(--accent); box-shadow: 0 0 5px var(--accent); }
+.tile-head .dot.connecting { background: var(--warn); }
+.tile-head .dot.error { background: var(--danger); }
+
+.tile-title {
+ flex: 1;
+ min-width: 0;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--muted);
+ letter-spacing: 0.03em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.tile-btn {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ background: none;
+ border: none;
+ border-radius: 5px;
+ color: var(--muted);
+ font-size: 13px;
+ cursor: pointer;
+}
+.tile-btn:hover { background: var(--border); color: var(--text); }
+.tile-btn.close:hover { color: var(--danger); }
+.tile-btn svg { width: 13px; height: 13px; display: block; }
+
+.tile-body {
+ flex: 1;
+ position: relative;
+ min-height: 0;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.tile-resize {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ width: 20px;
+ height: 20px;
+ cursor: nwse-resize;
+ touch-action: none;
+ z-index: 5;
+ color: var(--muted);
+ opacity: 0.6;
+}
+.tile-resize:hover { opacity: 1; color: var(--text); }
+.tile-resize::after {
+ content: "";
+ position: absolute;
+ right: 4px;
+ bottom: 4px;
+ width: 9px;
+ height: 9px;
+ border-right: 2px solid currentColor;
+ border-bottom: 2px solid currentColor;
+ border-radius: 0 0 3px 0;
+}
+
+/* status message a module can show while degraded (also used by the shell
+ when a module fails to load) */
+.tile-msg {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 12px;
+ color: var(--muted);
+ font-size: 13px;
+ background: var(--panel);
+ z-index: 4;
+}
+
+/* ── per-tile settings popover ─────────────────────────────────────── */
+
+.tile-settings {
+ position: absolute;
+ top: 30px;
+ right: 6px;
+ z-index: 45;
+ min-width: 220px;
+ max-width: calc(100% - 12px);
+ max-height: calc(100% - 40px);
+ overflow-y: auto;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 10px;
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-bottom: 10px;
+ font-size: 12px;
+ color: var(--muted);
+}
+.field input[type="text"],
+.field input[type="number"],
+.field input[type="password"],
+.field select {
+ padding: 7px 9px;
+ font-size: 13px;
+ font-family: inherit;
+ color: var(--text);
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 7px;
+}
+.field input:focus, .field select:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+.field.check {
+ flex-direction: row;
+ align-items: center;
+ gap: 8px;
+ color: var(--text);
+ cursor: pointer;
+}
+.field.check input { width: 15px; height: 15px; accent-color: var(--accent); }
+
+/* ── modal dialogs (layout name prompt etc.) ───────────────────────── */
+
+#modal-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 90;
+ background: rgba(4, 6, 8, 0.65);
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding-top: 18vh;
+}
+#modal-backdrop[hidden] { display: none; }
+
+#modal {
+ width: min(360px, calc(100vw - 32px));
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 16px;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
+}
+#modal h2 { font-size: 14px; margin-bottom: 12px; }
+#modal .actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 4px;
+}
+
+.btn {
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 6px 12px;
+ font-size: 13px;
+ cursor: pointer;
+}
+.btn:hover { border-color: var(--muted); }
+.btn.primary {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #06281a;
+ font-weight: 600;
+}
+
+/* toast */
+#toast {
+ position: fixed;
+ bottom: 18px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 95;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ color: var(--text);
+ font-size: 13px;
+ border-radius: 999px;
+ padding: 8px 16px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
+ transition: opacity 0.25s ease;
+}
+#toast[hidden] { display: none; }
+#toast.error { border-color: var(--danger); }
+
+/* ── scrollbars ────────────────────────────────────────────────────── */
+
+::-webkit-scrollbar { width: 10px; height: 10px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 999px;
+ border: 2px solid transparent; /* inset gutter */
+ background-clip: padding-box;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: #364253;
+ background-clip: padding-box;
+}
+::-webkit-scrollbar-corner { background: transparent; }
+
+/* Firefox (webkit pseudo-elements unsupported there) */
+@supports (-moz-appearance: none) {
+ * { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
+}
+
+@media (max-width: 560px) {
+ #topbar { padding: 6px 8px; flex-wrap: wrap; }
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..1401577
--- /dev/null
+++ b/server.js
@@ -0,0 +1,131 @@
+#!/usr/bin/env node
+/**
+ * ProdDash — modular production dashboard for Waters Church.
+ *
+ * Shell server: serves the dashboard client from public/, discovers modules
+ * in modules/, mounts each enabled module's server routes under
+ * /api/modules//, and stores server-side config + named layouts under
+ * config/.
+ *
+ * Zero dependencies — Node built-ins only (Node 18+ for global fetch).
+ * Configure via config/proddash.json or env vars:
+ * PORT (default: value in proddash.json, else 24500)
+ * PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
+ */
+'use strict';
+
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const ROOT = __dirname;
+const PUBLIC_DIR = path.join(ROOT, 'public');
+const CONFIG_DIR = path.join(ROOT, 'config');
+
+/* ── config ─────────────────────────────────────────────────────────── */
+
+function readJson(file, fallback) {
+ try {
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
+ } catch {
+ return fallback;
+ }
+}
+
+const shellConfig = readJson(path.join(CONFIG_DIR, 'proddash.json'), {});
+const PORT = Number(process.env.PORT || shellConfig.port || 24500);
+
+/* ── static files ───────────────────────────────────────────────────── */
+
+const MIME = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.mjs': 'text/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.svg': 'image/svg+xml',
+ '.ico': 'image/x-icon',
+ '.woff2': 'font/woff2',
+};
+
+/** Serve a file from inside baseDir; urlPath is relative to it. */
+function serveFile(res, baseDir, urlPath) {
+ let rel;
+ try {
+ rel = decodeURIComponent(urlPath).replace(/^\/+/, '');
+ } catch {
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
+ return res.end('Bad request');
+ }
+ if (rel === '') rel = 'index.html';
+ // Resolve inside baseDir only — never let a crafted path escape the web root.
+ const target = path.resolve(baseDir, rel);
+ if (target !== baseDir && !target.startsWith(baseDir + path.sep)) {
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
+ return res.end('Forbidden');
+ }
+ fs.stat(target, (serr, stat) => {
+ const file = !serr && stat.isDirectory() ? path.join(target, 'index.html') : target;
+ fs.readFile(file, (err, data) => {
+ if (err) {
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
+ return res.end('Not found');
+ }
+ const ext = path.extname(file).toLowerCase();
+ // Icons can cache; everything else must always revalidate so UI updates
+ // reach open dashboards immediately.
+ const noCache = ext !== '.png' && ext !== '.ico' && ext !== '.svg' && ext !== '.woff2';
+ res.writeHead(200, {
+ 'Content-Type': MIME[ext] || 'application/octet-stream',
+ 'Cache-Control': noCache ? 'no-cache' : 'public, max-age=3600',
+ });
+ res.end(data);
+ });
+ });
+}
+
+/* ── request routing ────────────────────────────────────────────────── */
+
+const server = http.createServer((req, res) => {
+ // Everything is guarded: one throw must never take the dashboard down mid-service.
+ try {
+ let urlPath;
+ try {
+ urlPath = new URL(req.url, 'http://proddash.invalid').pathname;
+ } catch {
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
+ return res.end('Bad request');
+ }
+
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
+ res.writeHead(405, { Allow: 'GET, HEAD' });
+ return res.end('Method not allowed');
+ }
+ serveFile(res, PUBLIC_DIR, urlPath);
+ } catch (err) {
+ console.error('[proddash] request error:', err);
+ try {
+ if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'text/plain' });
+ res.end('Server error');
+ } catch { /* connection already gone */ }
+ }
+});
+
+// Never time out long-lived SSE connections.
+server.requestTimeout = 0;
+
+server.listen(PORT, '0.0.0.0', () => {
+ console.log('ProdDash running');
+ console.log(' Local : http://localhost:' + PORT);
+ for (const ifaces of Object.values(os.networkInterfaces())) {
+ for (const iface of ifaces || []) {
+ if (iface.family === 'IPv4' && !iface.internal) {
+ console.log(' Network : http://' + iface.address + ':' + PORT);
+ }
+ }
+ }
+});