Merge pull request #14 from michaelmcgary2008/claude/proddash-app-tweaks
Shell: quieter menu handle, calmer fullscreen, live tile settings
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 —
|
||||||
|
|||||||
Reference in New Issue
Block a user