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..3b2e20c 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); @@ -518,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; @@ -536,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(); @@ -550,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 }; @@ -595,6 +660,7 @@ function buildDivider(L, R, top, bottom) { el.addEventListener('pointercancel', onUp); }); + dividers.push({ el, place, boundary }); grid.appendChild(el); } @@ -634,6 +700,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 +995,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 +1012,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 +1097,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 +1111,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 +1122,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 +1131,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 +1143,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 +1316,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 +1484,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..85df025 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; @@ -433,8 +435,9 @@ body.menu-hidden #topbar-notch::after { 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; @@ -447,6 +450,7 @@ body.menu-hidden #topbar-notch::after { justify-content: center; cursor: ns-resize; touch-action: none; + color: var(--muted); opacity: 0; transition: opacity 0.15s ease; } @@ -454,17 +458,13 @@ body.menu-hidden #topbar-notch::after { 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 ───────────────────────────── */ @@ -494,7 +494,8 @@ body.menu-hidden #topbar-notch::after { 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 —