diff --git a/README.md b/README.md
index c0ca582..3d03f1a 100644
--- a/README.md
+++ b/README.md
@@ -198,13 +198,17 @@ below. Nothing is installed without someone clicking.
- **+** — add any enabled module; multiple tiles of the same module are
fine (e.g. two transcripts filtered to different channels).
-- Drag a tile by its **title bar** to move it; drag either **bottom
- corner** to resize; **✕** removes it. Hover a tile and a small pill
- appears on its **bottom edge** — drag it to change just the height. If
- another tile sits directly below, the pill moves the shared edge instead,
- trading rows between the two so the column keeps its total height. Tiles
- sit flush — when two share a vertical edge, hover the seam and the same
- kind of pill appears; drag it to trade width (macOS split-view style).
+- Drag a tile by its **title bar** to move it; **✕** removes it.
+- **Resize from any edge or bottom corner**, the way a window resizes:
+ nothing is drawn on a tile to mark the spots — the cursor over an edge is
+ what says it can be dragged. An edge moves only itself, so the tile grows
+ or shrinks into the space beside it while the opposite edge stays put,
+ stopping at a neighbour or at the module's own minimum size. Two
+ exceptions, both of which move a *shared* edge instead: with another tile
+ directly below, the bottom edge trades rows between the two so the column
+ keeps its total height; and where two tiles meet side by side, the seam
+ between them trades width (macOS split-view style), showing itself only
+ while you drag it.
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its
green lip at the top-left brings it back, minus the Admin button), the
occupied columns stretch to the full width, and every column runs to the
@@ -214,9 +218,21 @@ below. Nothing is installed without someone clicking.
gap; alone in its columns it stays as it is. Nothing is saved — the layout
reverts when you leave fullscreen. A layout deeper than the screen keeps
its normal row height and scrolls.
-- A tile's actions (menus, text size, clear…) live in its **title bar**;
- the small **notch** on the bar's bottom edge hides/shows the whole bar
- for a clean wall-display look. The menu bar's handle is the **ProdDash
+- A tile's actions (menus, text size, clear…) live in its **title bar**.
+- **Auto-hide bars** (the button left of ⛶) folds every title bar away, for
+ a windowed screen that should read like a wall display. A folded bar is
+ not a gone bar: point at the tile's top edge and it slides back in over
+ the content, where its buttons click and its width drags the tile exactly
+ as before — so a dashboard can be arranged with every bar out of the way.
+ The switch is off on a fresh dashboard and saves with the layout —
+ including a named layout on the server, so a booth loads its screen with
+ the bars already tucked away. Fullscreen folds every bar regardless, and
+ the same hover brings one back there too.
+- The **pin** beside a tile's title keeps *that* tile's bar showing while
+ auto-hide is on — for the one or two tiles whose controls are wanted at
+ a glance. It turns the accent colour when it is on and saves with the
+ layout. Fullscreen still folds a pinned bar with the rest.
+- The menu bar's own handle is the **ProdDash
name itself**, wrapped in a translucent green pill at the left — click it
to hide the bar; the slim green lip it leaves at the top-left edge brings
the bar back. It stays at the left so it never sits on top of a tile's
diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md
index acaafe1..da69532 100644
--- a/docs/MODULE-GUIDE.md
+++ b/docs/MODULE-GUIDE.md
@@ -367,9 +367,10 @@ fallback), never a "Text size" field in `instanceSchema`; a timestamps or
newest-first toggle in the header means no such switch in the gear. The
gear is for display choices that have no button of their own.
-One more thing to know: the user can hide the whole title bar with the
-small notch on its bottom edge (a clean-view mode for wall displays). Your
-controls disappear with it, so nothing essential — status, reconnects,
+One more thing to know: the whole title bar folds away when the dashboard
+auto-hides it, and always in fullscreen (a clean-view mode for wall
+displays) — it comes back only while the pointer is at the tile's top edge.
+Your controls fold away with it, so nothing essential — status, reconnects,
live data — may exist *only* as a header control.
`sse(path, handlers)` handlers:
diff --git a/package.json b/package.json
index 88de406..32e1974 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "proddash",
- "version": "1.5.0",
+ "version": "1.6.0",
"private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED",
diff --git a/public/index.html b/public/index.html
index a1bfa52..c494d74 100644
--- a/public/index.html
+++ b/public/index.html
@@ -19,6 +19,14 @@
+
+
+
+
+
+
+
+
diff --git a/public/shell.js b/public/shell.js
index d9d24d8..9d9294e 100644
--- a/public/shell.js
+++ b/public/shell.js
@@ -24,6 +24,7 @@ function toast(msg, isError = false) {
toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200);
}
+const autoHideBtn = document.getElementById('autohide-btn');
const modalBackdrop = document.getElementById('modal-backdrop');
const modalEl = document.getElementById('modal');
@@ -161,18 +162,25 @@ async function loadRegistry() {
let tiles = [];
const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
+/* Whether tile title bars fold away until you point at them. It is part of
+ how a dashboard is arranged rather than a preference of this browser, so
+ it saves with the layout — including a named layout on the server — and a
+ fresh dashboard starts with the bars showing. Fullscreen folds every bar
+ away regardless; this is the windowed equivalent, asked for by hand. */
+let autoHideHeads = false;
+
function saveLayout() {
try {
- localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, tiles }));
+ localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, autoHideHeads, tiles }));
} catch { /* private mode / full — layout just won't persist */ }
}
function loadLayoutFromStorage() {
try {
const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null');
- if (raw && Array.isArray(raw.tiles)) return raw.tiles;
+ if (raw && Array.isArray(raw.tiles)) return raw;
} catch { /* corrupted — start fresh */ }
- return [];
+ return { tiles: [] };
}
function sanitizeTile(t) {
@@ -185,7 +193,7 @@ function sanitizeTile(t) {
w,
h: Math.max(1, Number(t.h) || 2),
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
- headHidden: Boolean(t.headHidden),
+ headPinned: Boolean(t.headPinned),
// set when the tile was added from a module's tile list (multi-tile modules)
variant: t.variant ? String(t.variant) : '',
title: t.title ? String(t.title) : '',
@@ -249,9 +257,48 @@ function minSizeOf(tile) {
};
}
+/* ── title bars that fold away ──────────────────────────────────────── */
+
+/* A bar is "off" when it is folded out of sight: the dashboard has auto-hide
+ on and this tile is not pinned, or the screen is in fullscreen, which folds
+ every bar for watching whatever the tiles say. Off is never gone — the top
+ edge of the tile brings it back for as long as the pointer is there. */
+
+/** How close to the tile's top edge the pointer opens a folded-away bar. */
+const HEAD_REVEAL_PX = 22;
+/** How far down it may wander before that bar folds away again (bar + a little). */
+const HEAD_HOLD_PX = 38;
+
+function headIsOff(tile) {
+ if (document.body.classList.contains('fullscreen')) return true;
+ return autoHideHeads && !tile.headPinned;
+}
+
+function applyHeadState(tile, el = tileEls.get(tile.id)?.el) {
+ if (!el) return;
+ const off = headIsOff(tile);
+ el.classList.toggle('head-off', off);
+ if (!off) el.classList.remove('head-peek');
+}
+
+/** Re-read every tile's bar — after the toggle, or after fullscreen changes. */
+function applyAllHeadStates() {
+ for (const tile of tiles) applyHeadState(tile);
+}
+
+function setAutoHideHeads(on, { save = true } = {}) {
+ autoHideHeads = Boolean(on);
+ document.body.classList.toggle('autohide-heads', autoHideHeads);
+ autoHideBtn?.classList.toggle('is-on', autoHideHeads);
+ autoHideBtn?.setAttribute('aria-pressed', autoHideHeads ? 'true' : 'false');
+ applyAllHeadStates();
+ if (save) saveLayout();
+}
+
/* ── tile DOM ───────────────────────────────────────────────────────── */
const CLOSE_SVG = ' ';
+const PIN_SVG = ' ';
const GEAR_SVG = ' ';
function buildTile(tile) {
@@ -278,7 +325,34 @@ function buildTile(tile) {
const controls = document.createElement('span');
controls.className = 'tile-controls';
- head.append(dot, title, controls);
+ /* Pinned tiles keep their bar through auto-hide. The switch sits right
+ beside the label rather than out with the actions, because it is about
+ this bar rather than about the tile's contents. */
+ const pin = document.createElement('button');
+ pin.className = 'tile-btn pin';
+ pin.innerHTML = PIN_SVG;
+ const paintPin = () => {
+ const on = Boolean(tile.headPinned);
+ pin.classList.toggle('is-pinned', on);
+ pin.setAttribute('aria-pressed', on ? 'true' : 'false');
+ // Instant tooltips read `title` the first time and `data-tip` after.
+ pin.title = on ? 'Unpin bar' : 'Pin bar';
+ pin.setAttribute('data-tip', pin.title);
+ };
+ paintPin();
+ pin.addEventListener('click', (e) => {
+ e.stopPropagation();
+ tile.headPinned = !tile.headPinned;
+ paintPin();
+ applyHeadState(tile, el);
+ saveLayout();
+ });
+
+ // Keeps the label hugging the pin however wide the tile gets.
+ const gap = document.createElement('span');
+ gap.className = 'tile-head-gap';
+
+ head.append(dot, title, pin, gap, controls);
// Every tile has something to set: its own settings when the module
// offers any, and the tint either way.
@@ -307,45 +381,47 @@ function buildTile(tile) {
const body = document.createElement('div');
body.className = 'tile-body';
- const resize = document.createElement('div');
- resize.className = 'tile-resize';
- resize.title = 'Drag to resize';
+ /* The rim: both bottom corners resize in two directions, the bottom edge
+ moves that edge (trading rows with tiles below), and the other three
+ move only themselves — the top edge changes where the tile starts, the
+ sides how wide it is — each stopping where a neighbour or the tile's own
+ minimum says it must. None of them draw anything: the cursor over an
+ edge is what says it can be dragged, as on a window. */
+ const grip = (cls) => {
+ const gripEl = document.createElement('div');
+ gripEl.className = cls;
+ return gripEl;
+ };
+ const resize = grip('tile-resize');
+ const resizeLeft = grip('tile-resize-left');
+ const resizeBottom = grip('tile-resize-bottom');
+ const edgeTop = grip('tile-resize-top');
+ const edgeRight = grip('tile-resize-right');
+ const edgeLeft = grip('tile-resize-side');
- const resizeLeft = document.createElement('div');
- resizeLeft.className = 'tile-resize-left';
- resizeLeft.title = 'Drag to resize';
-
- // pill on the bottom edge: moves that edge (trading rows with tiles below)
- const resizeBottom = document.createElement('div');
- resizeBottom.className = 'tile-resize-bottom';
- resizeBottom.title = 'Drag to adjust height';
-
- // notch on the title bar's bottom edge: hides/shows the bar
- const notch = document.createElement('button');
- notch.className = 'tile-notch';
- 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 folded-away bar is still reachable: point at the tile's top edge and it
+ // slides in over the content, where its buttons click and its whole width
+ // drags the tile exactly as when it is showing.
+ el.addEventListener('pointermove', (ev) => {
+ if (!headIsOff(tile)) return;
+ // Mid-drag the tile moves under the pointer, and mid-resize the edges do
+ // — neither is a moment to take the bar away.
+ if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
+ const y = ev.clientY - el.getBoundingClientRect().top;
+ const shown = el.classList.contains('head-peek');
+ // It takes less to hold the bar open than to open it, so a bar that
+ // appears under the pointer cannot flicker straight back out.
+ if (!shown && y <= HEAD_REVEAL_PX) el.classList.add('head-peek');
+ else if (shown && y > HEAD_HOLD_PX) el.classList.remove('head-peek');
});
- // 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);
+ if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
+ el.classList.remove('head-peek');
});
- el.append(head, body, resize, resizeLeft, resizeBottom, notch);
- el.classList.toggle('head-hidden', Boolean(tile.headHidden));
+ el.append(head, body, resize, resizeLeft, resizeBottom, edgeTop, edgeRight, edgeLeft);
+ applyHeadState(tile, el);
grid.appendChild(el);
const entry = { el, body, dot, titleEl: title, controls, instance: null };
@@ -356,6 +432,9 @@ function buildTile(tile) {
wireResize(tile, el, resize, false);
wireResize(tile, el, resizeLeft, true);
wireBottomResize(tile, el, resizeBottom);
+ wireEdgeResize(tile, el, edgeTop, 'n');
+ wireEdgeResize(tile, el, edgeRight, 'e');
+ wireEdgeResize(tile, el, edgeLeft, 'w');
mountModule(tile);
return entry;
@@ -369,7 +448,7 @@ function refreshEmptyState() {
function wireDrag(tile, el, handle) {
handle.addEventListener('pointerdown', (ev) => {
- if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu') || ev.target.closest('.tile-notch')) return;
+ if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu')) return;
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault();
@@ -486,6 +565,80 @@ function wireResize(tile, el, handle, fromLeft) {
});
}
+/* ── drag the top, left or right edge ───────────────────────────────── */
+
+/* One edge moves and the opposite one stays where it is, so the tile grows
+ or shrinks into the space beside it. Unlike the bottom-edge pill these do
+ not trade rows or columns with a neighbour: the edge simply stops when it
+ meets one, which is what "resize this tile" should mean. */
+
+function wireEdgeResize(tile, el, handle, edge) {
+ handle.addEventListener('pointerdown', (ev) => {
+ if (ev.button !== 0 && ev.pointerType === 'mouse') return;
+ if (document.body.classList.contains('fullscreen')) return;
+ ev.preventDefault();
+ ev.stopPropagation();
+ closeSettingsPopovers();
+ handle.setPointerCapture(ev.pointerId);
+ const m = cellMetrics();
+ const sx = ev.clientX;
+ const sy = ev.clientY;
+ const startX = tile.x;
+ const startY = tile.y;
+ const startW = tile.w;
+ const startH = tile.h;
+ const min = minSizeOf(tile);
+ el.classList.add('resizing');
+ grid.classList.add('no-dividers');
+ let changed = false;
+
+ const onMove = (e) => {
+ const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
+ const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
+ let cand;
+ if (edge === 'n') {
+ // the top edge moves; the bottom edge stays put
+ const y = Math.max(0, Math.min(startY + startH - min.h, startY + dRows));
+ cand = { x: tile.x, y, w: tile.w, h: startY + startH - y };
+ } else if (edge === 'w') {
+ // the left edge moves; the right edge stays put
+ const x = Math.max(0, Math.min(startX + startW - min.w, startX + dCols));
+ cand = { x, y: tile.y, w: startX + startW - x, h: tile.h };
+ } else {
+ cand = {
+ x: tile.x,
+ y: tile.y,
+ w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)),
+ h: tile.h,
+ };
+ }
+ if (cand.x === tile.x && cand.y === tile.y && cand.w === tile.w && cand.h === tile.h) return;
+ if (!isFree(cand, tile.id)) return;
+ tile.x = cand.x;
+ tile.y = cand.y;
+ tile.w = cand.w;
+ tile.h = cand.h;
+ applyRect(tile);
+ changed = true;
+ notifyResize(tile);
+ };
+ const onUp = () => {
+ handle.removeEventListener('pointermove', onMove);
+ handle.removeEventListener('pointerup', onUp);
+ handle.removeEventListener('pointercancel', onUp);
+ el.classList.remove('resizing');
+ grid.classList.remove('no-dividers');
+ if (changed) {
+ saveLayout();
+ rebuildDividers();
+ }
+ };
+ handle.addEventListener('pointermove', onMove);
+ handle.addEventListener('pointerup', onUp);
+ handle.addEventListener('pointercancel', onUp);
+ });
+}
+
/* ── drag the bottom edge: height only ──────────────────────────────── */
/* The pill on a tile's bottom edge moves that edge. When other tiles sit
@@ -613,7 +766,6 @@ function seamPairsAt(boundary) {
function buildDivider(L, R, top, bottom) {
const el = document.createElement('div');
el.className = 'tile-divider';
- el.title = 'Drag to resize this column';
const place = () => {
const m = cellMetrics();
@@ -626,14 +778,6 @@ function buildDivider(L, R, top, bottom) {
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;
@@ -689,7 +833,6 @@ function buildDivider(L, R, top, bottom) {
el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp);
for (const other of group) other.classList.remove('active');
- unlink();
if (changed) saveLayout();
rebuildDividers(); // adjacencies may have changed
};
@@ -727,7 +870,7 @@ function addTile(moduleId, entry = null) {
if (spec && 'default' in spec) settings[key] = spec.default;
}
Object.assign(settings, entry?.settings || {});
- const tile = { id: newId(), module: moduleId, ...spot, ...size, settings };
+ const tile = { id: newId(), module: moduleId, ...spot, ...size, settings, headPinned: false };
if (entry) {
tile.variant = String(entry.id);
tile.title = String(entry.name || '');
@@ -743,7 +886,6 @@ 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);
@@ -997,7 +1139,7 @@ function buildModuleApi(tile, man, entry) {
/**
* The tile's title bar. Controls added here live next to the gear/close
* buttons and are removed automatically when the instance stops. The
- * whole bar can be hidden by the user via the notch, so nothing
+ * whole bar folds away when the dashboard auto-hides it, so nothing
* essential should exist ONLY as a header control.
*/
header: {
@@ -1712,9 +1854,13 @@ function remountTile(tile) {
/* ── layout apply / reset ───────────────────────────────────────────── */
-function renderLayout(list) {
+function renderLayout(layout) {
+ // Callers pass a whole layout; an array on its own is still understood so
+ // "reset" can simply hand over no tiles.
+ const list = Array.isArray(layout) ? layout : (layout?.tiles || []);
clearAllTiles();
tiles = list.map(sanitizeTile);
+ if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
for (const tile of tiles) buildTile(tile);
refreshEmptyState();
rebuildDividers();
@@ -1794,7 +1940,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ layout: { tiles } }),
+ body: JSON.stringify({ layout: { autoHideHeads, tiles } }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
@@ -1838,7 +1984,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
// Loading copies the layout into this browser's own state — it
// does not live-link browsers together.
- renderLayout(body.layout.tiles || []);
+ renderLayout(body.layout);
saveLayout();
toast(`Loaded “${l.name}”`);
} catch (e) {
@@ -1850,7 +1996,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
menu.insertBefore(block, reset);
});
-/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */
+/* ── menu bar show/hide (its own notch, at the top-left) ────────────── */
const topbar = document.getElementById('topbar');
const topbarNotch = document.getElementById('topbar-notch');
@@ -1877,41 +2023,6 @@ 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
@@ -2026,6 +2137,13 @@ function clearFullscreenFit() {
}
}
+autoHideBtn?.addEventListener('click', () => {
+ setAutoHideHeads(!autoHideHeads);
+ toast(autoHideHeads
+ ? 'Title bars hide until you point at a tile\u2019s top edge'
+ : 'Title bars stay put');
+});
+
fsBtn.addEventListener('click', () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen?.().catch(() => {});
@@ -2039,14 +2157,15 @@ document.addEventListener('fullscreenchange', () => {
document.body.classList.toggle('fullscreen', active);
if (active) {
closeSettingsPopovers();
+ applyAllHeadStates(); // fullscreen has its own rule for the bars
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
// let the collapsed menubar's space settle before measuring
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
} else {
setMenuHidden(menuHiddenBeforeFs, { persist: false });
- clearPeeks();
clearFullscreenFit();
+ applyAllHeadStates();
}
});
diff --git a/public/style.css b/public/style.css
index dfb8491..03b880f 100644
--- a/public/style.css
+++ b/public/style.css
@@ -126,6 +126,14 @@ body.menu-hidden #topbar {
line-height: 1;
}
.tb-btn.icon svg { display: block; }
+/* A top-bar button that is a switch rather than an action lights up the same
+ way an open tile button does, so "on" reads the same everywhere. */
+.tb-btn.is-on,
+.tb-btn.is-on:hover {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: var(--accent-text);
+}
/* ── dropdown menus ────────────────────────────────────────────────── */
@@ -270,11 +278,33 @@ body.menu-hidden #topbar {
transition: height 0.22s ease, border-bottom-width 0.22s ease;
}
.tile.dragging .tile-head { cursor: grabbing; }
-/* hidden = collapsed, not removed, so the notch toggle animates */
-.tile.head-hidden .tile-head {
- height: 0;
- border-bottom-width: 0;
- overflow: hidden;
+
+/* Folded away: the bar leaves the flow and parks just above the tile, so the
+ module keeps the whole body and nothing reflows as the bar comes and goes.
+ Pointing at the tile's top edge slides it back in over the content
+ (shell.js adds .head-peek) — and because it is the same element, its
+ buttons click and its width drags the tile just as they always did. */
+.tile.head-off > .tile-head {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 7;
+ background: color-mix(in srgb, var(--panel) 90%, transparent);
+ -webkit-backdrop-filter: blur(7px);
+ backdrop-filter: blur(7px);
+ transform: translateY(-100%);
+ opacity: 0;
+ pointer-events: none;
+ transition: transform 0.18s ease, opacity 0.18s ease;
+}
+.tile.head-off.head-peek > .tile-head {
+ transform: none;
+ opacity: 1;
+ pointer-events: auto;
+}
+.tile.is-tinted.head-off > .tile-head {
+ background: color-mix(in srgb, var(--tile-tint) 20%, var(--panel));
}
.tile-head .dot {
@@ -290,7 +320,7 @@ body.menu-hidden #topbar {
.tile-head .dot.error { background: var(--danger); }
.tile-title {
- flex: 1;
+ flex: 0 1 auto;
min-width: 0;
font-size: 12px;
font-weight: 700;
@@ -321,6 +351,33 @@ body.menu-hidden #topbar {
.tile-btn.is-open,
.tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); }
+/* The pin: a state, not an action, so it stays quiet until it is on and
+ sits closer to the label than to the buttons at the other end. It fades
+ in with the rest of the tile's chrome so a wall of unpinned tiles is not
+ a wall of pins. */
+.tile-btn.pin {
+ width: 18px;
+ height: 18px;
+ margin-left: 2px;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+}
+.tile-btn.pin svg { width: 12px; height: 12px; }
+.tile:hover .tile-btn.pin,
+.tile-btn.pin:focus-visible { opacity: 0.75; }
+.tile-btn.pin:hover { opacity: 1; }
+/* Pinned reads at a glance, whether or not anything is being pointed at. */
+.tile-btn.pin.is-pinned,
+.tile:hover .tile-btn.pin.is-pinned {
+ opacity: 1;
+ color: var(--accent);
+}
+.tile-btn.pin.is-pinned .pin-head { fill: currentColor; }
+.tile-btn.pin.is-pinned:hover { background: var(--border); }
+
+/* Keeps the label hugging its pin however wide the tile is. */
+.tile-head-gap { flex: 1 1 auto; min-width: 0; }
+
/* the shell's tooltip: shown at once under the button the pointer is on */
#tip {
position: fixed;
@@ -374,55 +431,6 @@ body.menu-hidden #topbar {
}
.tile-menu[hidden] { display: none; }
-/* notch on the title bar's bottom edge: click to hide/show the bar
- (same idea as the ProdCom Listener's pull-down menubar handle) */
-.tile-notch {
- position: absolute;
- top: 32px;
- left: 50%;
- transform: translateX(-50%);
- width: 46px;
- height: 13px;
- padding: 0;
- background: var(--panel);
- border: 1px solid var(--border);
- border-top: none;
- border-radius: 0 0 10px 10px;
- cursor: pointer;
- z-index: 6;
- opacity: 0;
- transition: opacity 0.15s ease, top 0.22s ease;
-}
-.tile:hover .tile-notch { opacity: 0.6; }
-.tile .tile-notch:hover { opacity: 1; }
-.tile.head-hidden .tile-notch { top: 0; }
-.tile-notch::after {
- content: "";
- display: block;
- width: 18px;
- height: 3px;
- margin: 4px auto 0;
- border-radius: 999px;
- background: var(--muted);
-}
-.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;
@@ -431,80 +439,67 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
overflow: hidden;
}
+/* ── resizing: the whole rim of a tile, drawn nowhere ────────────────
+ Every edge and both bottom corners resize, so there is nothing to point
+ at — the grips are invisible and the cursor says what each one does, the
+ way a window's own edges do. Together they tile the tile's outer 8px
+ without a gap: the top strip runs corner to corner, the sides run between
+ it and the bottom corners, and the bottom strip fills between those. */
+
+.tile-resize,
+.tile-resize-left,
+.tile-resize-bottom,
+.tile-resize-top,
+.tile-resize-right,
+.tile-resize-side {
+ position: absolute;
+ z-index: 5;
+ touch-action: none;
+}
+
+/* the two bottom corners: both directions at once */
.tile-resize,
.tile-resize-left {
- position: absolute;
bottom: 0;
width: 20px;
height: 20px;
- touch-action: none;
- z-index: 5;
- color: var(--muted);
- opacity: 0; /* corner indicators only show while hovering the tile */
- transition: opacity 0.15s ease;
}
.tile-resize { right: 0; cursor: nwse-resize; }
.tile-resize-left { left: 0; cursor: nesw-resize; }
-.tile:hover .tile-resize,
-.tile:hover .tile-resize-left { opacity: 0.6; }
-.tile .tile-resize:hover,
-.tile .tile-resize-left:hover { opacity: 1; color: var(--text); }
-.tile-resize::after,
-.tile-resize-left::after {
- content: "";
- position: absolute;
- bottom: 4px;
- width: 9px;
- height: 9px;
- border-bottom: 2px solid currentColor;
-}
-.tile-resize::after {
- right: 4px;
- border-right: 2px solid currentColor;
- border-radius: 0 0 3px 0;
-}
-.tile-resize-left::after {
- left: 4px;
- border-left: 2px solid currentColor;
- border-radius: 0 0 0 3px;
-}
-/* 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. */
+/* top and bottom: height only */
+.tile-resize-top,
.tile-resize-bottom {
- position: absolute;
- left: 24px;
- right: 24px;
- bottom: 0;
- height: 10px;
- z-index: 5;
- display: flex;
- align-items: flex-end;
- justify-content: center;
+ height: 8px;
cursor: ns-resize;
- touch-action: none;
- color: var(--muted);
- opacity: 0;
- transition: opacity 0.15s ease;
}
-.tile-resize-bottom::after {
- content: "";
- width: 44px;
- max-width: 60%;
- height: 0;
- margin-bottom: 4px;
- border-bottom: 2px solid currentColor;
+.tile-resize-top {
+ top: 0;
+ left: 0;
+ right: 0;
+ /* Above a revealed title bar: the bar slides in under the pointer here,
+ and this strip has to stay the top edge rather than become part of it. */
+ z-index: 8;
}
-.tile:hover .tile-resize-bottom { opacity: 0.6; }
-.tile .tile-resize-bottom:hover,
-.tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); }
+.tile-resize-bottom { bottom: 0; left: 20px; right: 20px; }
+
+/* left and right: width only, between the top strip and the bottom corners */
+.tile-resize-right,
+.tile-resize-side {
+ top: 8px;
+ bottom: 20px;
+ width: 8px;
+ cursor: ew-resize;
+}
+.tile-resize-right { right: 0; }
+.tile-resize-side { left: 0; }
/* ── seam divider between adjacent tiles ───────────────────────────── */
-/* When two tiles share a vertical edge, an invisible strip sits on the seam;
- hovering reveals a small macOS-split-view-style grab pill. Dragging it
- trades width between the two tiles. */
+/* When two tiles share a vertical edge, an invisible strip sits on the seam.
+ Dragging it trades width between the two tiles rather than resizing one,
+ so it sits above their own edge grips. Like them it draws nothing at rest
+ — only while it is being dragged, to show which seam is moving. */
.tile-divider {
position: absolute;
width: 14px;
@@ -528,22 +523,24 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
transition: opacity 0.15s ease, background 0.15s ease;
}
-.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 —
- they're rebuilt for the new geometry on release. */
+/* Drop the seams while a tile itself is being moved or resized — they're
+ rebuilt for the new geometry on release. */
#grid.no-dividers .tile-divider { display: none; }
/* ── fullscreen mode ───────────────────────────────────────────────── */
/* Fullscreen is a viewing mode: the layout auto-fits the screen, so the
- editing affordances (corner handles, seam pills) are parked. */
+ editing affordances (the rim grips and the seams) are parked. */
body.fullscreen .tile-resize,
body.fullscreen .tile-resize-left,
body.fullscreen .tile-resize-bottom,
+body.fullscreen .tile-resize-top,
+body.fullscreen .tile-resize-right,
+body.fullscreen .tile-resize-side,
body.fullscreen .tile-divider,
+body.fullscreen #autohide-btn,
body.fullscreen #admin-link { display: none; }
body.fullscreen #grid { padding-bottom: 0; }
#gridwrap.no-scroll { overflow-y: hidden; }
diff --git a/server.js b/server.js
index 08de07b..1d85a7f 100644
--- a/server.js
+++ b/server.js
@@ -1539,15 +1539,30 @@ function layoutPath(name) {
function sanitizeLayout(raw) {
const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : [];
return {
- tiles: tiles.map((t) => ({
- id: String(t?.id || ''),
- module: String(t?.module || ''),
- x: Number(t?.x) || 0,
- y: Number(t?.y) || 0,
- w: Number(t?.w) || 1,
- h: Number(t?.h) || 1,
- settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
- })),
+ // Whether tile title bars auto-hide is part of how a dashboard is
+ // arranged, so it travels with the layout like the tiles themselves.
+ autoHideHeads: Boolean(raw?.autoHideHeads),
+ tiles: tiles.map((t) => {
+ const tile = {
+ id: String(t?.id || ''),
+ module: String(t?.module || ''),
+ x: Number(t?.x) || 0,
+ y: Number(t?.y) || 0,
+ w: Number(t?.w) || 1,
+ h: Number(t?.h) || 1,
+ settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
+ // Which picker entry the tile came from, and what it was called.
+ // Without these a saved "Now" tile comes back as the full Now/Next
+ // card, and a solo timer comes back with a full tile's settings.
+ variant: String(t?.variant || ''),
+ title: String(t?.title || ''),
+ headPinned: Boolean(t?.headPinned),
+ };
+ if (Number(t?.minSize?.w) > 0 && Number(t?.minSize?.h) > 0) {
+ tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) };
+ }
+ return tile;
+ }),
};
}