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 @@
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 —