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) };