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;