From 7f50fa0ba981e6614bb4425583a8838ebf03d3bc Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 17 Sep 2026 12:04:59 -0400 Subject: [PATCH] A pin on the title bar, and no more notch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pointing at a tile's top edge already brings its bar back, which left the notch doing half a job: it could still hide a bar, but hiding one tile at a time stopped being the interesting question once the whole dashboard could fold its bars at once. It is gone. In its place, a pin beside each tile's label. Pinned, that tile keeps its bar while auto-hide is on — for the one or two tiles whose controls are wanted at a glance. It sits next to the label rather than out with the gear and ✕ because it is about the bar, not about the tile's contents, and it stays faint until the tile is pointed at or the pin is on, so a wall of unpinned tiles is not a wall of pins. Fullscreen keeps its own rule: every bar folds, pinned or not, because that is a mode for watching. What it loses with the notch it gets back better — the same hover at a tile's top edge peeks a bar there too, and now as an overlay, so peeking no longer pushes the tile's content down and disturbs the auto-fit. The peek timer the notch needed goes with it. Per-tile `headHidden` gives way to `headPinned` in the layout, on disk and on the server. Co-Authored-By: Claude Opus 5 --- README.md | 27 +++++----- docs/MODULE-GUIDE.md | 7 +-- public/shell.js | 118 +++++++++++++++---------------------------- public/style.css | 87 ++++++++++++------------------- server.js | 2 +- 5 files changed, 93 insertions(+), 148 deletions(-) diff --git a/README.md b/README.md index 5d733fc..d856325 100644 --- a/README.md +++ b/README.md @@ -209,18 +209,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. A hidden bar is not gone: 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. -- **Auto-hide bars** (the button left of ⛶) folds every title bar away at - once, for a windowed screen that should read like a wall display. It 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 the bars away regardless; this is the - windowed equivalent, asked for by hand. 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/public/shell.js b/public/shell.js index ab3deb0..9d9294e 100644 --- a/public/shell.js +++ b/public/shell.js @@ -193,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) : '', @@ -259,10 +259,10 @@ function minSizeOf(tile) { /* ── title bars that fold away ──────────────────────────────────────── */ -/* A bar is "off" when it is folded out of sight but the tile is still being - worked on — either this tile's own notch hid it, or the dashboard has - auto-hide switched on. Fullscreen is not one of these: there the bars fold - away for watching, and arranging is switched off entirely. */ +/* 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; @@ -270,8 +270,8 @@ const HEAD_REVEAL_PX = 22; const HEAD_HOLD_PX = 38; function headIsOff(tile) { - if (document.body.classList.contains('fullscreen')) return false; - return autoHideHeads || Boolean(tile.headHidden); + if (document.body.classList.contains('fullscreen')) return true; + return autoHideHeads && !tile.headPinned; } function applyHeadState(tile, el = tileEls.get(tile.id)?.el) { @@ -298,6 +298,7 @@ function setAutoHideHeads(on, { save = true } = {}) { /* ── tile DOM ───────────────────────────────────────────────────────── */ const CLOSE_SVG = ''; +const PIN_SVG = ''; const GEAR_SVG = ''; function buildTile(tile) { @@ -324,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. @@ -371,23 +399,6 @@ function buildTile(tile) { const edgeRight = grip('tile-resize-right'); const edgeLeft = grip('tile-resize-side'); - // 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; - applyHeadState(tile, el); - 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. @@ -404,20 +415,12 @@ function buildTile(tile) { 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')) return; if (el.classList.contains('dragging') || el.classList.contains('resizing')) return; - // Windowed, the bar is a hover-reveal and goes the moment the pointer - // does. In fullscreen it was opened deliberately from the notch, so it - // lingers (PEEK_HIDE_MS) before folding away again. - if (document.body.classList.contains('fullscreen')) schedulePeekHide(tile.id, el); - else el.classList.remove('head-peek'); + el.classList.remove('head-peek'); }); - el.append(head, body, resize, resizeLeft, resizeBottom, edgeTop, edgeRight, edgeLeft, notch); + el.append(head, body, resize, resizeLeft, resizeBottom, edgeTop, edgeRight, edgeLeft); applyHeadState(tile, el); grid.appendChild(el); @@ -445,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(); @@ -867,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 || ''); @@ -883,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); @@ -1137,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: { @@ -1994,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'); @@ -2021,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 @@ -2197,7 +2164,6 @@ document.addEventListener('fullscreenchange', () => { requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit)); } else { setMenuHidden(menuHiddenBeforeFs, { persist: false }); - clearPeeks(); clearFullscreenFit(); applyAllHeadStates(); } diff --git a/public/style.css b/public/style.css index b3638da..03b880f 100644 --- a/public/style.css +++ b/public/style.css @@ -284,7 +284,7 @@ body.menu-hidden #topbar { 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. */ -body:not(.fullscreen) .tile.head-off > .tile-head { +.tile.head-off > .tile-head { position: absolute; top: 0; left: 0; @@ -298,17 +298,14 @@ body:not(.fullscreen) .tile.head-off > .tile-head { pointer-events: none; transition: transform 0.18s ease, opacity 0.18s ease; } -body:not(.fullscreen) .tile.head-off.head-peek > .tile-head { +.tile.head-off.head-peek > .tile-head { transform: none; opacity: 1; pointer-events: auto; } -body:not(.fullscreen) .tile.is-tinted.head-off > .tile-head { +.tile.is-tinted.head-off > .tile-head { background: color-mix(in srgb, var(--tile-tint) 20%, var(--panel)); } -/* Auto-hide already reveals on hover, so the notch would be a dead control; - a bar this tile hid by itself keeps its notch, which is how it comes back. */ -body.autohide-heads .tile-notch { display: none; } .tile-head .dot { flex: none; @@ -323,7 +320,7 @@ body.autohide-heads .tile-notch { display: none; } .tile-head .dot.error { background: var(--danger); } .tile-title { - flex: 1; + flex: 0 1 auto; min-width: 0; font-size: 12px; font-weight: 700; @@ -354,6 +351,33 @@ body.autohide-heads .tile-notch { display: none; } .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; @@ -407,55 +431,6 @@ body.autohide-heads .tile-notch { display: none; } } .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; } -body:not(.fullscreen) .tile.head-off .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; diff --git a/server.js b/server.js index e6c69a6..b2941d0 100644 --- a/server.js +++ b/server.js @@ -1411,7 +1411,7 @@ function sanitizeLayout(raw) { // card, and a solo timer comes back with a full tile's settings. variant: String(t?.variant || ''), title: String(t?.title || ''), - headHidden: Boolean(t?.headHidden), + 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) };