From fb66d56f7dc6326cf7173fddca142dcf1205ecc1 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Fri, 11 Sep 2026 18:40:31 -0400 Subject: [PATCH 1/4] Shell: a quieter menu handle, a calmer fullscreen, live tile settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the dashboard was making people work for. **The menu handle.** It was the ProdDash wordmark on a pill hanging past the bar's bottom edge, which read as a step cut out of the bar. The wordmark goes back in the bar where it belongs, and the handle becomes a small accent lip on the bar's bottom edge — the same idea as a tile's notch — that grows under the pointer so it stays easy to hit without being loud at rest. That also fixes the animation Safari made a meal of: the old handle transitioned font-size and line-height, animating text metrics frame by frame on the way out. Nothing here animates text. The lip rides `--topbar-h`, the bar's live height, so it tracks the bar's own collapse instead of running a second animation against it, and its hover growth is width, height and opacity only. **Fullscreen.** Fullscreen is for watching, not arranging, so every tile's title bar now folds away on the way in. A notch still peeks one open, and it folds back ten seconds after the pointer leaves that tile — coming back to the tile cancels the countdown. Peeking is a transient class, so a tile's own hidden/shown preference is untouched and comes back with the screen. **Tile settings.** Gear settings are display choices — a text size, a filter, a colour — so they apply as they are changed and save with the layout. Apply and Cancel are gone; close by clicking away, pressing Escape, or the gear again. Typing is debounced so a remount doesn't land on every keystroke, and an unchanged value remounts nothing (a text field fires input and then change for one edit). The module guide says so now too. Verified in the browser: settings apply live and persist, no-op changes cost nothing, all three close paths work, and in fullscreen both heads fold, one peeks, it folds back at ten seconds, returning the pointer cancels it, and leaving fullscreen restores everything with no peeks left behind. Co-Authored-By: Claude Opus 5 --- docs/MODULE-GUIDE.md | 13 +++-- package.json | 2 +- public/index.html | 5 +- public/shell.js | 119 +++++++++++++++++++++++++++++++++++-------- public/style.css | 86 ++++++++++++++++--------------- 5 files changed, 153 insertions(+), 72 deletions(-) diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 5497829..64ca0c2 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -97,7 +97,7 @@ the app); its settings are kept for a later reinstall. | `style` | no | A stylesheet the shell injects once per module. | | `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. | | `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. | -| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). | +| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. | | `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. | Both schemas map field names to specs: @@ -341,10 +341,13 @@ own in `stop()` anyway. - **Any number of instances.** Two transcript tiles with different filters is a feature. Keep all state inside `create()`'s closure — no module-level mutable state in `client.js`. -- **Remounts are routine.** When the user edits the tile's gear settings the - shell calls `stop()` and starts a fresh instance. When admin config - changes, the shell calls your `onConfigChange(cfg)` if you export one, - otherwise it remounts you. Either way you must come back cleanly. +- **Remounts are routine.** Gear settings apply as the user changes them — + there is no Apply button — so each change calls `stop()` and starts a fresh + instance (typing is debounced, and an unchanged value remounts nothing). + Starting cheaply matters: hold connections and timers in the closure and + tear them down in `stop()`. When admin config changes, the shell calls your + `onConfigChange(cfg)` if you export one, otherwise it remounts you. Either + way you must come back cleanly. - **The tile can be removed at any moment.** `stop()` is your only notice. ### Rules for the client diff --git a/package.json b/package.json index 15fe326..ae778d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proddash", - "version": "1.3.0", + "version": "1.4.0", "private": true, "description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client", "license": "UNLICENSED", diff --git a/public/index.html b/public/index.html index ccffe69..35f8f08 100644 --- a/public/index.html +++ b/public/index.html @@ -15,8 +15,7 @@
- - + ProdDash @@ -51,7 +50,7 @@
- +
diff --git a/public/shell.js b/public/shell.js index 5a8fc98..7f46805 100644 --- a/public/shell.js +++ b/public/shell.js @@ -288,11 +288,24 @@ function buildTile(tile) { notch.title = 'Hide / show the title bar'; notch.addEventListener('click', (e) => { e.stopPropagation(); + // In fullscreen the bars are all folded away for watching; the notch + // peeks one open for a moment without touching what this tile saved. + if (document.body.classList.contains('fullscreen')) { + peekHead(tile, el, !el.classList.contains('head-peek')); + return; + } tile.headHidden = !tile.headHidden; el.classList.toggle('head-hidden', tile.headHidden); saveLayout(); }); + // A peeked title bar stays while the pointer is on its tile, and starts + // counting down as soon as it leaves. + el.addEventListener('pointerenter', () => clearPeekTimer(tile.id)); + el.addEventListener('pointerleave', () => { + if (el.classList.contains('head-peek')) schedulePeekHide(tile.id, el); + }); + el.append(head, body, resize, resizeLeft, resizeBottom, notch); el.classList.toggle('head-hidden', Boolean(tile.headHidden)); grid.appendChild(el); @@ -634,6 +647,7 @@ function addTile(moduleId, entry = null) { function removeTile(id) { const idx = tiles.findIndex((t) => t.id === id); if (idx < 0) return; + clearPeekTimer(id); unmountModule(tiles[idx]); tiles.splice(idx, 1); const entry = tileEls.get(id); @@ -928,7 +942,10 @@ function colorHex(value, fallback) { } function closeSettingsPopovers() { - document.querySelectorAll('.tile-settings').forEach((p) => p.remove()); + document.querySelectorAll('.tile-settings').forEach((p) => { + p._cleanup?.(); + p.remove(); + }); } function toggleSettingsPopover(tile, el) { @@ -942,6 +959,34 @@ function toggleSettingsPopover(tile, el) { pop.className = 'tile-settings'; const inputs = new Map(); + /* These are display choices — a text size, a filter, a colour — so they + apply as they are changed and save with the layout. There is nothing to + confirm, so there is no Apply. Typing is debounced because applying + means remounting the module, which shouldn't happen per keystroke. */ + 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. + let changed = false; + for (const [key, read] of inputs) { + const value = read(); + if (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)); + }; + // Fields render in schema order; a field's `group` starts a labeled // subsection that consecutive same-group fields share, and the manifest's // optional `instanceGroups[name]` adds { collapsed, columns, help } — @@ -999,6 +1044,7 @@ function toggleSettingsPopover(tile, el) { input.checked = Boolean(current); field.append(input, document.createTextNode(label)); inputs.set(key, () => input.checked); + live(input); } else if (type === 'select' && Array.isArray(spec.options)) { const span = document.createElement('span'); span.textContent = label; @@ -1012,6 +1058,7 @@ function toggleSettingsPopover(tile, el) { select.value = String(current ?? ''); field.append(span, select); inputs.set(key, () => select.value); + live(select); } else if (type === 'color') { // A swatch picker; the value is always a #rrggbb string. field.classList.add('color-field'); @@ -1022,6 +1069,7 @@ function toggleSettingsPopover(tile, el) { input.value = colorHex(current, colorHex(spec?.default, '#2ee59a')); field.append(span, input); inputs.set(key, () => input.value); + live(input, 'input', 150); // dragging the picker fires continuously } else { const span = document.createElement('span'); span.textContent = label; @@ -1030,6 +1078,8 @@ function toggleSettingsPopover(tile, el) { input.value = current === undefined || current === null ? '' : String(current); field.append(span, input); inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value)); + live(input, 'input', 400); // mid-word is not the moment to remount + live(input, 'change'); // …but blur or Enter is } if (spec?.help) { const help = document.createElement('small'); @@ -1040,26 +1090,17 @@ function toggleSettingsPopover(tile, el) { container.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); + // Nothing to press: click away, press Escape, or click the gear again. + // (The gear's own click stops propagating, so it toggles rather than + // closing and reopening.) + const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); }; + const closeOnEscape = (e) => { if (e.key === 'Escape') closeSettingsPopovers(); }; + pop._cleanup = () => { + document.removeEventListener('click', closeOnClick); + document.removeEventListener('keydown', closeOnEscape); + }; + document.addEventListener('click', closeOnClick); + document.addEventListener('keydown', closeOnEscape); pop.addEventListener('pointerdown', (e) => e.stopPropagation()); el.appendChild(pop); @@ -1222,6 +1263,41 @@ try { if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false }); } catch { /* fine */ } +/* ── fullscreen: peeking at a tile's title bar ──────────────────────── */ + +/** How long a peeked title bar stays after the pointer leaves its tile. */ +const PEEK_HIDE_MS = 10000; +const peekTimers = new Map(); // tile id → timeout + +function clearPeekTimer(id) { + const timer = peekTimers.get(id); + if (timer === undefined) return; + clearTimeout(timer); + peekTimers.delete(id); +} + +function schedulePeekHide(id, el) { + clearPeekTimer(id); + peekTimers.set(id, setTimeout(() => { + peekTimers.delete(id); + el.classList.remove('head-peek'); + }, PEEK_HIDE_MS)); +} + +function peekHead(tile, el, on) { + el.classList.toggle('head-peek', on); + clearPeekTimer(tile.id); + // Opened from a click the pointer is on the tile, so the countdown waits + // for it to leave — unless it already has (a keyboard or touch open). + if (on && !el.matches(':hover')) schedulePeekHide(tile.id, el); +} + +/** Back to a plain screen: no peeks left open, no timers left running. */ +function clearPeeks() { + for (const id of [...peekTimers.keys()]) clearPeekTimer(id); + document.querySelectorAll('.tile.head-peek').forEach((el) => el.classList.remove('head-peek')); +} + /* ── fullscreen: auto-fit the layout to the screen ──────────────────── */ /* Fullscreen is a viewing mode. The occupied columns stretch proportionally @@ -1355,6 +1431,7 @@ document.addEventListener('fullscreenchange', () => { requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit)); } else { setMenuHidden(menuHiddenBeforeFs, { persist: false }); + clearPeeks(); clearFullscreenFit(); } }); diff --git a/public/style.css b/public/style.css index 2ff8649..5a1f509 100644 --- a/public/style.css +++ b/public/style.css @@ -54,57 +54,43 @@ body.menu-hidden #topbar { overflow: hidden; } -/* The menubar's show/hide handle IS the logo: a translucent accent pill - wrapped around "ProdDash" at the left of the bar, "Dash" cut out in the - background colour. It sits at the left — never screen-centre, where a - tile's own (centred) tab lands once both bars are hidden. When the bar - hides, the pill shrinks to a slim lip at the very top edge, clear of a - top-row tile's status dot and title. */ -.brand-spacer { visibility: hidden; } - +/* The bar's show/hide handle: a small accent lip on its bottom edge, the + same idea as a tile's notch. It sits at the left — never screen-centre, + where a tile's own (centred) tab lands once both bars are hidden — and + grows under the pointer so it is easy to hit without being loud at rest. + It used to be the ProdDash wordmark on a pill hanging past the bar, which + read as a step cut out of the bar, and animating its font-size on the way + out was the jerkiest thing on the page in Safari. Everything here moves by + transform, opacity and its own fixed size: no text metrics, no reflow. */ #topbar-notch { position: fixed; - /* a tab hung from the top of the bar: square top, rounded bottom, hanging - 8px past the bar's bottom edge; the logo sits centred in the bar part */ top: 0; - left: 2px; /* the logo text starts at the bar's 12px padding; the tab adds 10px each side */ - height: calc(var(--topbar-h, 46px) + 8px); - min-width: 60px; - padding: 0 10px; - font: inherit; - font-weight: 800; - font-size: 15px; - letter-spacing: 0.02em; - line-height: var(--topbar-h, 46px); - color: var(--text); - background: color-mix(in srgb, var(--accent) 45%, transparent); + left: 10px; + width: 28px; + height: 5px; + padding: 0; border: none; - border-radius: 0 0 12px 12px; + border-radius: 0 0 5px 5px; + background: var(--accent); + opacity: 0.5; cursor: pointer; z-index: 60; - transition: height 0.22s ease, line-height 0.22s ease, font-size 0.22s ease, background 0.15s ease; + /* Glued to the bar's bottom edge: --topbar-h is the bar's live height, so + the lip rides the bar's own collapse instead of running a second, + competing animation against it. */ + transform: translateY(var(--topbar-h, 46px)); + transition: width 0.16s ease, height 0.16s ease, + opacity 0.16s ease, border-radius 0.16s ease; } -#topbar-notch .accent { color: var(--bg); } /* the cutout */ -#topbar-notch:hover { background: color-mix(in srgb, var(--accent) 68%, transparent); } -/* bar hidden: the text folds away and the tab becomes a lip at the top edge */ -body.menu-hidden #topbar-notch { +#topbar-notch:hover, +#topbar-notch:focus-visible { + width: 60px; height: 10px; - line-height: 10px; - font-size: 0; - background: color-mix(in srgb, var(--accent) 55%, transparent); -} -body.menu-hidden #topbar-notch:hover { background: var(--accent); } -body.menu-hidden #topbar-notch::after { - content: ""; - display: block; - width: 20px; - height: 3px; - margin: 0 auto; - position: relative; - top: 3px; - border-radius: 999px; - background: var(--bg); + opacity: 1; + border-radius: 0 0 8px 8px; } +/* With the bar collapsed --topbar-h is 0, so the lip ends up at the very top + edge without a rule of its own. */ #brand { font-weight: 800; @@ -387,6 +373,22 @@ body.menu-hidden #topbar-notch::after { } .tile-notch:hover::after { background: var(--text); } +/* Fullscreen is for watching, not arranging: every title bar folds away, and + a tile's own hidden/shown preference is left untouched for when the screen + comes back. The notch still peeks one open, and it folds back once the + pointer has been off that tile for a while (PEEK_HIDE_MS in shell.js). */ +body.fullscreen .tile:not(.head-peek) .tile-head { + height: 0; + border-bottom-width: 0; + overflow: hidden; +} +body.fullscreen .tile:not(.head-peek) .tile-notch { top: 0; } +body.fullscreen .tile.head-peek .tile-head { + height: 32px; + border-bottom-width: 1px; +} +body.fullscreen .tile.head-peek .tile-notch { top: 32px; } + .tile-body { flex: 1; position: relative; From 804dc483d6a4a4c2c44e590e2a5c2df5b13520b6 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Fri, 11 Sep 2026 18:45:20 -0400 Subject: [PATCH 2/4] Shell: draw the bottom resize handle like the corner ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was a filled rounded pill among two corner brackets drawn as 2px lines — three marks along the same edge in two different languages. Now it is a 2px line in currentColor on the same 4px inset, with the same muted-to-text hover, so the bottom edge reads as one set. Co-Authored-By: Claude Opus 5 --- public/style.css | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/public/style.css b/public/style.css index 5a1f509..d7731f1 100644 --- a/public/style.css +++ b/public/style.css @@ -435,8 +435,9 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; } border-radius: 0 0 0 3px; } -/* bottom-edge pill: drag to move the tile's bottom edge (height only); - sits between the two corner handles */ +/* bottom edge: drag to move the tile's bottom edge (height only). Drawn as + the same 2px line as the corner handles it sits between, on the same + 4px inset, so the three read as one set of marks along the bottom. */ .tile-resize-bottom { position: absolute; left: 24px; @@ -449,6 +450,7 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; } justify-content: center; cursor: ns-resize; touch-action: none; + color: var(--muted); opacity: 0; transition: opacity 0.15s ease; } @@ -456,17 +458,13 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; } content: ""; width: 44px; max-width: 60%; - height: 5px; - margin-bottom: 2px; - border-radius: 999px; - background: var(--muted); - transition: background 0.15s ease; + height: 0; + margin-bottom: 4px; + border-bottom: 2px solid currentColor; } .tile:hover .tile-resize-bottom { opacity: 0.6; } .tile .tile-resize-bottom:hover, -.tile.resizing .tile-resize-bottom { opacity: 1; } -.tile .tile-resize-bottom:hover::after, -.tile.resizing .tile-resize-bottom::after { background: var(--text); } +.tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); } /* ── seam divider between adjacent tiles ───────────────────────────── */ From 7b353cf7f8878ed412da65a6787442ba2870a2e7 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Fri, 11 Sep 2026 18:52:14 -0400 Subject: [PATCH 3/4] Shell: a seam drag moves the whole column, not one pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging a seam moved exactly the two tiles touching it, so a column edge shared by several rows had to be dragged row by row and drifted out of line in between. A drag now moves that column boundary wherever it appears: every pair of tiles meeting at the same x, in every row, travels with it. Each pair still trades width within its own fixed span, so no row can push into another, and the whole column stops at whichever row runs out of room first — one row's minimum width holds the line for all of them. All rows move or none do; a column edge that bends halfway is worse than one that won't budge. Rows are only grouped by a shared seam. A row split somewhere else keeps its own boundary, and a row where one tile simply spans across has no seam there and stays put. Hovering a seam lights up the others that travel with it, so what is about to move is visible before the drag starts. Verified in the browser: two rows split at the same column move together from one drag; rows split at different columns stay independent; a tall tile beside two stacked ones resizes all three; and a 4-column drag stopped at 1 because the lower tile's minimum width said so. Co-Authored-By: Claude Opus 5 --- public/shell.js | 99 +++++++++++++++++++++++++++++++++++++----------- public/style.css | 3 +- 2 files changed, 78 insertions(+), 24 deletions(-) diff --git a/public/shell.js b/public/shell.js index 7f46805..3b2e20c 100644 --- a/public/shell.js +++ b/public/shell.js @@ -531,13 +531,22 @@ function notifyResize(tile) { /* ── seam dividers between adjacent tiles ───────────────────────────── */ /* Wherever two tiles share a vertical edge, a divider sits on the seam. - Dragging its pill moves the shared edge, trading width between the two - tiles — their 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). */ + 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; @@ -549,10 +558,24 @@ function rebuildDividers() { } } +/** 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'; - el.title = 'Drag to resize'; + el.title = 'Drag to resize this column'; const place = () => { const m = cellMetrics(); @@ -563,43 +586,72 @@ function buildDivider(L, R, top, bottom) { }; place(); + const boundary = () => L.x + L.w; + const groupEls = () => dividers.filter((d) => d.boundary() === boundary()).map((d) => d.el); + const unlink = () => { for (const d of dividers) d.el.classList.remove('linked'); }; + + // Before anything is dragged, show which seams travel with this one. + el.addEventListener('pointerenter', () => { + if (document.body.classList.contains('fullscreen')) return; + for (const other of groupEls()) other.classList.add('linked'); + }); + el.addEventListener('pointerleave', unlink); + 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); - el.classList.add('active'); + const m = cellMetrics(); const sx = ev.clientX; - const startBoundary = L.x + L.w; - const rightEdge = R.x + R.w; // fixed for the whole drag - const minB = L.x + minSizeOf(L).w; - const maxB = rightEdge - minSizeOf(R).w; + 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 === L.x + L.w) return; - const candL = { x: L.x, y: L.y, w: b - L.x, h: L.h }; - const candR = { x: b, y: R.y, w: rightEdge - b, h: R.h }; - if (!isFreeExcept(candL, [L.id, R.id]) || !isFreeExcept(candR, [L.id, R.id])) return; - L.w = candL.w; - R.x = candR.x; - R.w = candR.w; - applyRect(L); - applyRect(R); - notifyResize(L); - notifyResize(R); - place(); + 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); - el.classList.remove('active'); + for (const other of group) other.classList.remove('active'); + unlink(); if (changed) saveLayout(); rebuildDividers(); // adjacencies may have changed }; @@ -608,6 +660,7 @@ function buildDivider(L, R, top, bottom) { el.addEventListener('pointercancel', onUp); }); + dividers.push({ el, place, boundary }); grid.appendChild(el); } diff --git a/public/style.css b/public/style.css index d7731f1..85df025 100644 --- a/public/style.css +++ b/public/style.css @@ -494,7 +494,8 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; } transition: opacity 0.15s ease, background 0.15s ease; } -.tile-divider:hover::after { opacity: 0.7; } +.tile-divider:hover::after, +.tile-divider.linked::after { opacity: 0.7; } .tile-divider.active::after { opacity: 1; background: var(--text); } /* Hide the pills while a tile itself is being moved or corner-resized — From a6dc2ddb8520694ef42fe95c30f3c922726fdc1f Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 13 Sep 2026 12:32:31 -0400 Subject: [PATCH 4/4] Pin the contracts the next round of work meets at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four pieces of work are about to run in parallel — the shell, the admin page, the Timers module and the PCO Plan module — and three of them meet at things that didn't exist yet. This writes those down first, so they are built once, the same way. - **Offering timers to the Timers module.** A module declares `"provides": ["timers"]` and serves `GET /timers` (a cheap snapshot with startedAt/elapsedMs/targetMs, an optional status line, optionally an SSE stream). The consumer extrapolates locally, so no fast polling. Modules meet over loopback through each other's routes, as PCO Plan already does to follow ProPresenter. - **Admin group controls.** `configGroups[name].help` becomes an ⓘ beside the group title rather than a paragraph under it; `configGroups[name].toggle` names a switch that renders in the title row as the group's on/off. - **Switches, labelled with nouns.** On means shown or enabled; the label shouldn't say so twice. - **Themes** swap the shell variables; a tile's own configured colour is set closer and wins. Two shell hooks make it real: `/api/modules` forwards `provides`, and module servers get `shell.port` in init/routes/tiles instead of guessing it from proddash.json. Clock: descriptions gone, "Show seconds" is "Seconds" (1.0.1). Co-Authored-By: Claude Fable 5.1 --- docs/MODULE-GUIDE.md | 98 ++++++++++++++++++++++++++++++++++++--- modules/clock/module.json | 7 ++- server.js | 11 +++-- 3 files changed, 103 insertions(+), 13 deletions(-) diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 64ca0c2..699bfab 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -92,6 +92,7 @@ the app); its settings are kept for a later reinstall. | `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. | | `description` | no | Shown in the admin page / picker. | | `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. | +| `provides` | no | Capabilities other modules can consume — `["timers"]` today. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. | | `client` | yes | Client entry file, loaded as an ES module. | | `server` | no | Server entry file (CommonJS), loaded with `require()`. | | `style` | no | A stylesheet the shell injects once per module. | @@ -115,8 +116,13 @@ Both schemas map field names to specs: } ``` -`boolean` renders as a checkbox; `switch` is the same true/false value shown -as a toggle (use it for a feature on/off that other fields depend on). +`boolean` and `switch` are the same true/false value. In a tile's gear popover +both render as a switch; in the admin page `switch` is the toggle and +`boolean` the plainer checkbox (use `switch` for a feature on/off that other +fields depend on). **Label a switch with the noun of what it turns on** — +"Seconds", "Top bar", "Section headers" — never "Show seconds": on means +shown or enabled, off means hidden or disabled, and the label shouldn't say +so twice. `color` renders a swatch picker and stores a `#rrggbb` string (give it a hex default; the tile applies it as a CSS custom property — see `pco-plan`). @@ -141,7 +147,15 @@ the group name: still empty. Omit it and the group is a plain, always-open block. - `columns`: 2–4 lays the group's fields out in a grid — the way to keep a long checklist of booleans compact. -- `help`: a line of guidance at the top of the group. +- `help`: guidance for the whole group. The admin page shows it as an + ⓘ beside the group title (click or hover to read), so a group can carry a + paragraph without pushing its fields down the page. Say where a value comes + from, not what a field is. +- `toggle`: the key of a `switch` field in this group. It renders in the + group's title row, at the right, as the group's on/off; the rest of the group + dims while it is off, and the field is not repeated in the body. This is how + an integration is disabled without unmounting the module — ProPresenter + timers, say, on a Sunday when ProPresenter isn't running. `help` on a field puts one line of guidance under its control — allowed placeholders, where a value comes from. Keep labels short and put the @@ -365,7 +379,11 @@ own in `stop()` anyway. palette: `--bg` (page), `--panel` (tile), `--border`, `--text`, `--muted`, `--accent`, `--danger`, `--warn`. Derive tints with `color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes - every module look native on the dark, high-contrast booth theme. + every module look native — and what makes the dashboard's **themes** + (Admin → Theme) reach into every tile: a theme swaps those variables and + nothing else. A colour a tile is configured with (a `color` instance + setting, applied as a custom property on the tile) is set closer than the + theme and wins over it, by design. 3. **No external network calls from the client.** Browsers talk only to ProdDash. Anything upstream (your device, service, API) goes through your @@ -385,7 +403,7 @@ ethos; Node 18+ so global `fetch` is available). Exports either or both of: ```js module.exports = { - init({ config, log }) { + init({ config, log, shell }) { // Called on startup / re-init, BEFORE routes(). Start your upstream // connection here. Return a handle: return { @@ -394,7 +412,7 @@ module.exports = { }; }, - routes({ config, log }) { + routes({ config, log, shell }) { // Route table, mounted under /api/modules// return { 'GET /state': (req, res) => { … }, @@ -410,6 +428,9 @@ module.exports = { - `config` is your admin config: `configSchema` defaults overlaid with what the admin page saved — **including password fields**. - `log(...)` prefixes output with your module id. +- `shell.port` is the port ProdDash is serving on — what you need to read + another module's routes over loopback (`http://127.0.0.1:${shell.port}/api/ + modules//…`). Never guess it. - Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is the matched remainder (leading `/` included) and `req.search` is the query string (with `?`, or `''`) — so a proxy rebuilds the upstream path as @@ -496,6 +517,71 @@ per-client `res.write` in try/catch (drop the client on failure). The `propresenter-now-next` module is the reference implementation of this pattern. +## Working with other modules + +Modules don't import each other. When one needs what another knows, it reads +that module's **own server routes over loopback**, exactly as a browser would: +`http://127.0.0.1:${shell.port}/api/modules//…`. The PCO Plan module +follows ProPresenter this way — it reads `propresenter-now-next`'s `/stream` +rather than talking to ProPresenter a second time. A consumer must survive the +provider being disabled, uninstalled, or restarting mid-service (404s and +dropped connections are normal; retry with backoff and say so in `health()`). + +### Offering timers to the Timers module + +The Timers module shows timers from anywhere: ProPresenter, LTC, its own +clock, and any module that offers some. A module offers timers by: + +1. Declaring it — `"provides": ["timers"]` in `module.json`. The shell + forwards `provides` in `/api/modules`, which is how the Timers module finds + providers without a list anyone maintains. +2. Serving `GET /timers` from its server routes, returning a snapshot: + +```json +{ + "source": "pco-plan", + "label": "PCO Plan", + "updatedAt": 1757800000000, + "timers": [ + { + "id": "service", + "label": "Service", + "kind": "elapsed", + "state": "running", + "startedAt": 1757799000000, + "elapsedMs": 1000000, + "remainingMs": null, + "targetMs": 4500000, + "status": { "text": "Welcome & Announcements", "tone": "ok" }, + "detail": "Item 3 of 12" + } + ] +} +``` + +| Field | Meaning | +| --- | --- | +| `source`, `label` | Your module id, and the heading the Timers module puts over your timers. | +| `updatedAt` | ms since epoch when this snapshot was taken. Every duration below is *as of this instant*. | +| `id` | Stable within your module — the Timers module keys tile settings (shown/hidden, colours) on `source` + `id`. | +| `kind` | `elapsed` (counts up), `countdown` (counts down to zero), `clock` (a time of day; put the ISO time in `status.text`). | +| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). | +| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. | +| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. | +| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. | +| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. | +| `detail` | Optional second line. | + +The consumer derives the displayed time locally from `startedAt`/`elapsedMs` +and its own clock, so a snapshot every couple of seconds is plenty — `/timers` +must be cheap to answer (serve from state you already hold; never call +upstream to answer it). Optionally also serve `GET /timers/stream` as SSE +(`event: timers`, `data:` the same object, on every change plus a heartbeat +comment every 15 s); the consumer uses the stream when it exists and polls +`/timers` otherwise. Only offer timers a production tech would want on a +wall — the Timers module lets them hide any of them per tile, but every entry +you add is a decision someone has to make. + ## Complete minimal example A working clock — copy-paste this as your starter. (A slightly fancier diff --git a/modules/clock/module.json b/modules/clock/module.json index f95087f..d90051c 100644 --- a/modules/clock/module.json +++ b/modules/clock/module.json @@ -1,9 +1,8 @@ { "id": "clock", "name": "Clock", - "version": "1.0.0", + "version": "1.0.1", "proddash": ">=1.1.0", - "description": "Big booth clock — the smallest possible ProdDash module", "client": "client.js", "style": "style.css", "minSize": { "w": 2, "h": 1 }, @@ -18,7 +17,7 @@ { "value": "12h", "label": "12-hour" } ] }, - "showSeconds": { "type": "boolean", "label": "Show seconds", "default": true }, - "showDate": { "type": "boolean", "label": "Show date", "default": false } + "showSeconds": { "type": "boolean", "label": "Seconds", "default": true }, + "showDate": { "type": "boolean", "label": "Date", "default": false } } } diff --git a/server.js b/server.js index 5655907..2bddb72 100644 --- a/server.js +++ b/server.js @@ -373,14 +373,17 @@ function mountModule(id) { // Re-require fresh config on every (re)mount; the module code itself stays cached. const mod = require(serverPath); const config = effectiveConfig(id); + // What a module needs to reach the rest of ProdDash — another module's + // routes over loopback, chiefly — without guessing. + const shell = { port: PORT }; if (typeof mod.init === 'function') { - entry.handle = mod.init({ config, log }) || null; + entry.handle = mod.init({ config, log, shell }) || null; } if (typeof mod.routes === 'function') { - entry.routes = parseRouteTable(mod.routes({ config, log })); + entry.routes = parseRouteTable(mod.routes({ config, log, shell })); } if (typeof mod.tiles === 'function') { - entry.tiles = () => mod.tiles({ config, log }); + entry.tiles = () => mod.tiles({ config, log, shell }); } log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : '')); } catch (err) { @@ -510,6 +513,8 @@ async function clientManifest(id) { instanceSchema: man.instanceSchema || {}, instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {}, hasServer: Boolean(man.server), + // capabilities other modules consume ("timers") — see the module guide + provides: Array.isArray(man.provides) ? man.provides.map(String) : [], config: clientConfig(id), tiles: await moduleTiles(id), };