Merge pull request #14 from michaelmcgary2008/claude/proddash-app-tweaks

Shell: quieter menu handle, calmer fullscreen, live tile settings
This commit is contained in:
Michael McGary
2026-09-15 15:24:27 -04:00
committed by GitHub
5 changed files with 239 additions and 106 deletions
+8 -5
View File
@@ -97,7 +97,7 @@ the app); its settings are kept for a later reinstall.
| `style` | no | A stylesheet the shell injects once per module. | | `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. | | `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. | | `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**. | | `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: 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 - **Any number of instances.** Two transcript tiles with different filters
is a feature. Keep all state inside `create()`'s closure — no module-level is a feature. Keep all state inside `create()`'s closure — no module-level
mutable state in `client.js`. mutable state in `client.js`.
- **Remounts are routine.** When the user edits the tile's gear settings the - **Remounts are routine.** Gear settings apply as the user changes them —
shell calls `stop()` and starts a fresh instance. When admin config there is no Apply button — so each change calls `stop()` and starts a fresh
changes, the shell calls your `onConfigChange(cfg)` if you export one, instance (typing is debounced, and an unchanged value remounts nothing).
otherwise it remounts you. Either way you must come back cleanly. 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. - **The tile can be removed at any moment.** `stop()` is your only notice.
### Rules for the client ### Rules for the client
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.3.0", "version": "1.4.0",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
+2 -3
View File
@@ -15,8 +15,7 @@
</head> </head>
<body> <body>
<header id="topbar"> <header id="topbar">
<!-- invisible twin of the logo: holds its place in the bar; the real logo is the handle below --> <span id="brand">Prod<span class="accent">Dash</span></span>
<span id="brand" class="brand-spacer" aria-hidden="true">Prod<span class="accent">Dash</span></span>
<span class="spacer"></span> <span class="spacer"></span>
@@ -51,7 +50,7 @@
</a> </a>
</header> </header>
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar">Prod<span class="accent">Dash</span></button> <button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar"></button>
<main id="gridwrap"> <main id="gridwrap">
<div id="grid"> <div id="grid">
+174 -44
View File
@@ -288,11 +288,24 @@ function buildTile(tile) {
notch.title = 'Hide / show the title bar'; notch.title = 'Hide / show the title bar';
notch.addEventListener('click', (e) => { notch.addEventListener('click', (e) => {
e.stopPropagation(); 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; tile.headHidden = !tile.headHidden;
el.classList.toggle('head-hidden', tile.headHidden); el.classList.toggle('head-hidden', tile.headHidden);
saveLayout(); 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.append(head, body, resize, resizeLeft, resizeBottom, notch);
el.classList.toggle('head-hidden', Boolean(tile.headHidden)); el.classList.toggle('head-hidden', Boolean(tile.headHidden));
grid.appendChild(el); grid.appendChild(el);
@@ -518,13 +531,22 @@ function notifyResize(tile) {
/* ── seam dividers between adjacent tiles ───────────────────────────── */ /* ── seam dividers between adjacent tiles ───────────────────────────── */
/* Wherever two tiles share a vertical edge, a divider sits on the seam. /* 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 Dragging one moves that column boundary wherever it appears: every pair of
tiles — their combined span never changes, so nothing else can collide tiles meeting at the same x, in every row, moves with it, so a column of
except via the explicit check below (a third tile can sit behind the tiles keeps its edge as one line rather than drifting row by row. Hovering
seam for part of its height). */ 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() { function rebuildDividers() {
grid.querySelectorAll('.tile-divider').forEach((el) => el.remove()); grid.querySelectorAll('.tile-divider').forEach((el) => el.remove());
dividers.length = 0;
for (const L of tiles) { for (const L of tiles) {
for (const R of tiles) { for (const R of tiles) {
if (L === R || L.x + L.w !== R.x) continue; 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) { function buildDivider(L, R, top, bottom) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'tile-divider'; el.className = 'tile-divider';
el.title = 'Drag to resize'; el.title = 'Drag to resize this column';
const place = () => { const place = () => {
const m = cellMetrics(); const m = cellMetrics();
@@ -550,43 +586,72 @@ function buildDivider(L, R, top, bottom) {
}; };
place(); 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) => { el.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return; if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return; if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault(); ev.preventDefault();
closeSettingsPopovers(); closeSettingsPopovers();
el.setPointerCapture(ev.pointerId); el.setPointerCapture(ev.pointerId);
el.classList.add('active');
const m = cellMetrics(); const m = cellMetrics();
const sx = ev.clientX; const sx = ev.clientX;
const startBoundary = L.x + L.w; const startBoundary = boundary();
const rightEdge = R.x + R.w; // fixed for the whole drag // Every row that has a seam here moves with it. Each pair's outer edges
const minB = L.x + minSizeOf(L).w; // are fixed for the whole drag, so each row only trades width internally.
const maxB = rightEdge - minSizeOf(R).w; 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; let changed = false;
const onMove = (e) => { const onMove = (e) => {
const b = Math.max(minB, Math.min(maxB, const b = Math.max(minB, Math.min(maxB,
startBoundary + Math.round((e.clientX - sx) / (m.cw + m.gap)))); startBoundary + Math.round((e.clientX - sx) / (m.cw + m.gap))));
if (b === L.x + L.w) return; if (b === boundary()) return;
const candL = { x: L.x, y: L.y, w: b - L.x, h: L.h }; const next = parts.map((p) => ({
const candR = { x: b, y: R.y, w: rightEdge - b, h: R.h }; p,
if (!isFreeExcept(candL, [L.id, R.id]) || !isFreeExcept(candR, [L.id, R.id])) return; candL: { x: p.leftX, y: p.L.y, w: b - p.leftX, h: p.L.h },
L.w = candL.w; candR: { x: b, y: p.R.y, w: p.rightEdge - b, h: p.R.h },
R.x = candR.x; }));
R.w = candR.w; // All rows move or none do — a column edge that bends is worse than one
applyRect(L); // that won't budge.
applyRect(R); if (next.some(({ candL, candR }) =>
notifyResize(L); !isFreeExcept(candL, moving) || !isFreeExcept(candR, moving))) return;
notifyResize(R); for (const { p, candL, candR } of next) {
place(); 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; changed = true;
}; };
const onUp = () => { const onUp = () => {
el.removeEventListener('pointermove', onMove); el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerup', onUp); el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp); el.removeEventListener('pointercancel', onUp);
el.classList.remove('active'); for (const other of group) other.classList.remove('active');
unlink();
if (changed) saveLayout(); if (changed) saveLayout();
rebuildDividers(); // adjacencies may have changed rebuildDividers(); // adjacencies may have changed
}; };
@@ -595,6 +660,7 @@ function buildDivider(L, R, top, bottom) {
el.addEventListener('pointercancel', onUp); el.addEventListener('pointercancel', onUp);
}); });
dividers.push({ el, place, boundary });
grid.appendChild(el); grid.appendChild(el);
} }
@@ -634,6 +700,7 @@ function addTile(moduleId, entry = null) {
function removeTile(id) { function removeTile(id) {
const idx = tiles.findIndex((t) => t.id === id); const idx = tiles.findIndex((t) => t.id === id);
if (idx < 0) return; if (idx < 0) return;
clearPeekTimer(id);
unmountModule(tiles[idx]); unmountModule(tiles[idx]);
tiles.splice(idx, 1); tiles.splice(idx, 1);
const entry = tileEls.get(id); const entry = tileEls.get(id);
@@ -928,7 +995,10 @@ function colorHex(value, fallback) {
} }
function closeSettingsPopovers() { function closeSettingsPopovers() {
document.querySelectorAll('.tile-settings').forEach((p) => p.remove()); document.querySelectorAll('.tile-settings').forEach((p) => {
p._cleanup?.();
p.remove();
});
} }
function toggleSettingsPopover(tile, el) { function toggleSettingsPopover(tile, el) {
@@ -942,6 +1012,34 @@ function toggleSettingsPopover(tile, el) {
pop.className = 'tile-settings'; pop.className = 'tile-settings';
const inputs = new Map(); 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 // Fields render in schema order; a field's `group` starts a labeled
// subsection that consecutive same-group fields share, and the manifest's // subsection that consecutive same-group fields share, and the manifest's
// optional `instanceGroups[name]` adds { collapsed, columns, help } — // optional `instanceGroups[name]` adds { collapsed, columns, help } —
@@ -999,6 +1097,7 @@ function toggleSettingsPopover(tile, el) {
input.checked = Boolean(current); input.checked = Boolean(current);
field.append(input, document.createTextNode(label)); field.append(input, document.createTextNode(label));
inputs.set(key, () => input.checked); inputs.set(key, () => input.checked);
live(input);
} else if (type === 'select' && Array.isArray(spec.options)) { } else if (type === 'select' && Array.isArray(spec.options)) {
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = label; span.textContent = label;
@@ -1012,6 +1111,7 @@ function toggleSettingsPopover(tile, el) {
select.value = String(current ?? ''); select.value = String(current ?? '');
field.append(span, select); field.append(span, select);
inputs.set(key, () => select.value); inputs.set(key, () => select.value);
live(select);
} else if (type === 'color') { } else if (type === 'color') {
// A swatch picker; the value is always a #rrggbb string. // A swatch picker; the value is always a #rrggbb string.
field.classList.add('color-field'); field.classList.add('color-field');
@@ -1022,6 +1122,7 @@ function toggleSettingsPopover(tile, el) {
input.value = colorHex(current, colorHex(spec?.default, '#2ee59a')); input.value = colorHex(current, colorHex(spec?.default, '#2ee59a'));
field.append(span, input); field.append(span, input);
inputs.set(key, () => input.value); inputs.set(key, () => input.value);
live(input, 'input', 150); // dragging the picker fires continuously
} else { } else {
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = label; span.textContent = label;
@@ -1030,6 +1131,8 @@ function toggleSettingsPopover(tile, el) {
input.value = current === undefined || current === null ? '' : String(current); input.value = current === undefined || current === null ? '' : String(current);
field.append(span, input); field.append(span, input);
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value)); 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) { if (spec?.help) {
const help = document.createElement('small'); const help = document.createElement('small');
@@ -1040,26 +1143,17 @@ function toggleSettingsPopover(tile, el) {
container.appendChild(field); container.appendChild(field);
} }
const actions = document.createElement('div'); // Nothing to press: click away, press Escape, or click the gear again.
actions.className = 'actions'; // (The gear's own click stops propagating, so it toggles rather than
actions.style.display = 'flex'; // closing and reopening.)
actions.style.justifyContent = 'flex-end'; const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); };
actions.style.gap = '8px'; const closeOnEscape = (e) => { if (e.key === 'Escape') closeSettingsPopovers(); };
const cancel = document.createElement('button'); pop._cleanup = () => {
cancel.className = 'btn'; document.removeEventListener('click', closeOnClick);
cancel.textContent = 'Cancel'; document.removeEventListener('keydown', closeOnEscape);
cancel.addEventListener('click', () => pop.remove()); };
const apply = document.createElement('button'); document.addEventListener('click', closeOnClick);
apply.className = 'btn primary'; document.addEventListener('keydown', closeOnEscape);
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);
pop.addEventListener('pointerdown', (e) => e.stopPropagation()); pop.addEventListener('pointerdown', (e) => e.stopPropagation());
el.appendChild(pop); el.appendChild(pop);
@@ -1222,6 +1316,41 @@ try {
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false }); if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
} catch { /* fine */ } } 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: auto-fit the layout to the screen ──────────────────── */
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally /* Fullscreen is a viewing mode. The occupied columns stretch proportionally
@@ -1355,6 +1484,7 @@ document.addEventListener('fullscreenchange', () => {
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit)); requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
} else { } else {
setMenuHidden(menuHiddenBeforeFs, { persist: false }); setMenuHidden(menuHiddenBeforeFs, { persist: false });
clearPeeks();
clearFullscreenFit(); clearFullscreenFit();
} }
}); });
+54 -53
View File
@@ -54,57 +54,43 @@ body.menu-hidden #topbar {
overflow: hidden; overflow: hidden;
} }
/* The menubar's show/hide handle IS the logo: a translucent accent pill /* The bar's show/hide handle: a small accent lip on its bottom edge, the
wrapped around "ProdDash" at the left of the bar, "Dash" cut out in the same idea as a tile's notch. It sits at the left — never screen-centre,
background colour. It sits at the left — never screen-centre, where a where a tile's own (centred) tab lands once both bars are hidden — and
tile's own (centred) tab lands once both bars are hidden. When the bar grows under the pointer so it is easy to hit without being loud at rest.
hides, the pill shrinks to a slim lip at the very top edge, clear of a It used to be the ProdDash wordmark on a pill hanging past the bar, which
top-row tile's status dot and title. */ read as a step cut out of the bar, and animating its font-size on the way
.brand-spacer { visibility: hidden; } 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 { #topbar-notch {
position: fixed; 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; top: 0;
left: 2px; /* the logo text starts at the bar's 12px padding; the tab adds 10px each side */ left: 10px;
height: calc(var(--topbar-h, 46px) + 8px); width: 28px;
min-width: 60px; height: 5px;
padding: 0 10px; padding: 0;
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);
border: none; border: none;
border-radius: 0 0 12px 12px; border-radius: 0 0 5px 5px;
background: var(--accent);
opacity: 0.5;
cursor: pointer; cursor: pointer;
z-index: 60; 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,
#topbar-notch:hover { background: color-mix(in srgb, var(--accent) 68%, transparent); } #topbar-notch:focus-visible {
/* bar hidden: the text folds away and the tab becomes a lip at the top edge */ width: 60px;
body.menu-hidden #topbar-notch {
height: 10px; height: 10px;
line-height: 10px; opacity: 1;
font-size: 0; border-radius: 0 0 8px 8px;
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);
} }
/* 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 { #brand {
font-weight: 800; font-weight: 800;
@@ -387,6 +373,22 @@ body.menu-hidden #topbar-notch::after {
} }
.tile-notch:hover::after { background: var(--text); } .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 { .tile-body {
flex: 1; flex: 1;
position: relative; position: relative;
@@ -433,8 +435,9 @@ body.menu-hidden #topbar-notch::after {
border-radius: 0 0 0 3px; border-radius: 0 0 0 3px;
} }
/* bottom-edge pill: drag to move the tile's bottom edge (height only); /* bottom edge: drag to move the tile's bottom edge (height only). Drawn as
sits between the two corner handles */ 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 { .tile-resize-bottom {
position: absolute; position: absolute;
left: 24px; left: 24px;
@@ -447,6 +450,7 @@ body.menu-hidden #topbar-notch::after {
justify-content: center; justify-content: center;
cursor: ns-resize; cursor: ns-resize;
touch-action: none; touch-action: none;
color: var(--muted);
opacity: 0; opacity: 0;
transition: opacity 0.15s ease; transition: opacity 0.15s ease;
} }
@@ -454,17 +458,13 @@ body.menu-hidden #topbar-notch::after {
content: ""; content: "";
width: 44px; width: 44px;
max-width: 60%; max-width: 60%;
height: 5px; height: 0;
margin-bottom: 2px; margin-bottom: 4px;
border-radius: 999px; border-bottom: 2px solid currentColor;
background: var(--muted);
transition: background 0.15s ease;
} }
.tile:hover .tile-resize-bottom { opacity: 0.6; } .tile:hover .tile-resize-bottom { opacity: 0.6; }
.tile .tile-resize-bottom:hover, .tile .tile-resize-bottom:hover,
.tile.resizing .tile-resize-bottom { opacity: 1; } .tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); }
.tile .tile-resize-bottom:hover::after,
.tile.resizing .tile-resize-bottom::after { background: var(--text); }
/* ── seam divider between adjacent tiles ───────────────────────────── */ /* ── seam divider between adjacent tiles ───────────────────────────── */
@@ -494,7 +494,8 @@ body.menu-hidden #topbar-notch::after {
transition: opacity 0.15s ease, background 0.15s ease; 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); } .tile-divider.active::after { opacity: 1; background: var(--text); }
/* Hide the pills while a tile itself is being moved or corner-resized — /* Hide the pills while a tile itself is being moved or corner-resized —