Grid density: cut the board finer or coarser, per browser #14

Merged
michael merged 3 commits from claude/grid-size into main 2026-09-22 11:45:54 -04:00
7 changed files with 174 additions and 12 deletions
+8 -1
View File
@@ -273,7 +273,14 @@ below. Nothing is installed without someone clicking.
place and shows nothing, then appears when it is; it is never replaced by place and shows nothing, then appears when it is; it is never replaced by
the module's default tile. Saving over an existing name asks first, and the module's default tile. Saving over an existing name asks first, and
the server keeps the copy it replaced: **Restore previous** in Admin → the server keeps the copy it replaced: **Restore previous** in Admin →
Saved layouts swaps it back (and swapping again undoes that). Saved layouts swaps it back (and swapping again undoes that). The menu's
**Grid Size** stepper sets how many columns this browser cuts the board into —
8 to 24, 12 by default. Finer lets tiles be smaller so more fit across (a
4K wall), coarser suits a small screen read close up; row height follows,
so tiles keep their shape and simply change size. Going coarser, a tile
that no longer fits where it was moves to the first free spot rather than
onto another tile. It saves with the layout, so a named layout comes back
at the density it was arranged on.
- Every tile has a **status dot**: green = live, amber = connecting, - Every tile has a **status dot**: green = live, amber = connecting,
red = upstream unreachable (hover it for details). Tiles reconnect red = upstream unreachable (hover it for details). Tiles reconnect
automatically. automatically.
+1 -1
View File
@@ -136,7 +136,7 @@ the app); its settings are kept for a later reinstall.
| `client` | yes | Client entry file, loaded as an ES module. | | `client` | yes | Client entry file, loaded as an ES module. |
| `server` | no | Server entry file (CommonJS), loaded with `require()`. | | `server` | no | Server entry file (CommonJS), loaded with `require()`. |
| `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, declared for the default grid (12 columns, 72 px rows). Defaults: min 1×1, default 4×3. A dashboard may cut its board finer or coarser (8–24 columns, from the Layouts menu; row height follows). `defaultSize` is scaled to that density when a tile is added, so a new tile lands at about the size you meant; `minSize` is enforced in cells as declared, so on a finer grid a tile can be made smaller than it could at 12. |
| `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). They apply as they are changed, so keep them to display choices. | | `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**. |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.8.0", "version": "1.9.0",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
+126 -7
View File
@@ -5,7 +5,18 @@
settings) autosaves to this browser's localStorage — every browser is fully settings) autosaves to this browser's localStorage — every browser is fully
independent. Modules render inside tile bodies and never touch the shell. */ independent. Modules render inside tile bodies and never touch the shell. */
const COLS = 12; /* ── grid density ─────────────────────────────────────────────────────
How many columns the board is cut into. Twelve is the default; a finer
grid lets tiles be smaller and more of them fit across (a 4K wall), a
coarser one suits a small screen read close up. It is this browser's
choice — two displays at one location may want different densities — so
it saves with the layout, local and named, and a named layout comes back
at the density it was arranged on. Row height follows the column width, so
cells keep their shape: a 3×2 tile is still a 3×2 tile, only smaller. */
const GRID_STEPS = [8, 10, 12, 16, 20, 24];
const GRID_DEFAULT = 12;
const CELL_H_DEFAULT = 72; // px per row at the default density
let COLS = GRID_DEFAULT;
const LS_LAYOUT = 'proddash:layout'; const LS_LAYOUT = 'proddash:layout';
const grid = document.getElementById('grid'); const grid = document.getElementById('grid');
@@ -224,7 +235,7 @@ const LS_LAYOUT_PREVIOUS = LS_LAYOUT + '.previous';
function saveLayout() { function saveLayout() {
try { try {
const next = JSON.stringify({ v: 1, autoHideHeads, tiles }); const next = JSON.stringify({ v: 1, autoHideHeads, gridCols: COLS, tiles });
const current = localStorage.getItem(LS_LAYOUT); const current = localStorage.getItem(LS_LAYOUT);
if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current); if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current);
localStorage.setItem(LS_LAYOUT, next); localStorage.setItem(LS_LAYOUT, next);
@@ -275,6 +286,87 @@ function newId() {
/* ── grid geometry & collision ──────────────────────────────────────── */ /* ── grid geometry & collision ──────────────────────────────────────── */
function normalizeGridCols(value) {
const n = Number(value);
return GRID_STEPS.includes(n) ? n : GRID_DEFAULT;
}
function cellHeightFor(cols) {
return Math.round((CELL_H_DEFAULT * GRID_DEFAULT) / cols);
}
/** Cut the board into `cols` columns. Tiles keep their grid units — the
board gets denser or coarser and everything on it changes size with it.
A tile now past the right edge is pulled back in, and one that would then
sit on another moves to the first free spot instead. */
function setGridCols(value, { save = true } = {}) {
const next = normalizeGridCols(value);
const changed = next !== COLS;
COLS = next;
grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`;
document.documentElement.style.setProperty('--cell-h', cellHeightFor(COLS) + 'px');
if (changed) {
// A coarser board may not have room for every tile where it was. Settle
// them in reading order: pull a tile past the right edge back in, and
// give one that would then land on a tile already settled the first free
// spot instead — tiles never overlap here, any more than under a drag.
const settled = [];
for (const tile of tiles.slice().sort((a, b) => a.y - b.y || a.x - b.x)) {
tile.w = Math.max(1, Math.min(COLS, tile.w));
tile.x = Math.max(0, Math.min(COLS - tile.w, tile.x));
if (settled.some((s) => overlaps(tile, s))) Object.assign(tile, spotAmong(tile.w, tile.h, settled));
settled.push(tile);
applyRect(tile);
notifyResize(tile);
}
rebuildDividers();
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
}
if (save) saveLayout();
}
const STEP_MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
const STEP_PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
/** The Layouts menu's density row — Grid Size − 12 + — which steps without
closing the menu, so the board can be watched changing behind it. */
function gridStepper() {
const row = document.createElement('div');
row.className = 'menu-stepper';
const label = document.createElement('span');
label.className = 'menu-stepper-label';
label.textContent = 'Grid Size';
const minus = document.createElement('button');
minus.type = 'button';
minus.className = 'menu-stepper-btn';
minus.setAttribute('data-tip', 'Coarser');
minus.innerHTML = STEP_MINUS_SVG;
const value = document.createElement('span');
value.className = 'menu-stepper-value';
const plus = document.createElement('button');
plus.type = 'button';
plus.className = 'menu-stepper-btn';
plus.setAttribute('data-tip', 'Finer');
plus.innerHTML = STEP_PLUS_SVG;
const paint = () => {
const i = GRID_STEPS.indexOf(COLS);
value.textContent = String(COLS);
minus.disabled = i <= 0;
plus.disabled = i >= GRID_STEPS.length - 1;
};
const step = (dir) => {
const i = GRID_STEPS.indexOf(COLS) + dir;
if (i < 0 || i >= GRID_STEPS.length) return;
setGridCols(GRID_STEPS[i]);
paint();
};
minus.addEventListener('click', () => step(-1));
plus.addEventListener('click', () => step(1));
paint();
row.append(label, minus, value, plus);
return row;
}
function cellMetrics() { function cellMetrics() {
const style = getComputedStyle(grid); const style = getComputedStyle(grid);
const gapRaw = parseFloat(style.columnGap); const gapRaw = parseFloat(style.columnGap);
@@ -297,6 +389,17 @@ function isFree(rect, excludeId) {
return isFreeExcept(rect, [excludeId]); return isFreeExcept(rect, [excludeId]);
} }
/** First spot, scanning top-to-bottom, left-to-right, clear of these tiles. */
function spotAmong(w, h, others) {
for (let y = 0; y < 400; y += 1) {
for (let x = 0; x <= COLS - w; x += 1) {
const rect = { x, y, w, h };
if (!others.some((o) => overlaps(rect, o))) return { x, y };
}
}
return { x: 0, y: 0 };
}
/** First free spot scanning top-to-bottom, left-to-right. */ /** First free spot scanning top-to-bottom, left-to-right. */
function findSpot(w, h) { function findSpot(w, h) {
for (let y = 0; y < 200; y += 1) { for (let y = 0; y < 200; y += 1) {
@@ -990,10 +1093,14 @@ function addTile(moduleId, entry = null) {
toast(`${entry.name} is already on this dashboard`); toast(`${entry.name} is already on this dashboard`);
return; return;
} }
// Sizes are declared for the default twelve-column grid; scaled so a new
// tile lands at about the size its author meant, whatever the density here.
const scale = COLS / GRID_DEFAULT;
const defaultSize = entry?.defaultSize || man?.defaultSize; const defaultSize = entry?.defaultSize || man?.defaultSize;
const floor = minSizeOf({ module: moduleId, minSize: entry?.minSize });
const size = { const size = {
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)), w: Math.max(floor.w, 1, Math.min(COLS, Math.round((defaultSize?.w || 4) * scale))),
h: Math.max(1, defaultSize?.h || 3), h: Math.max(floor.h, 1, Math.round((defaultSize?.h || 3) * scale)),
}; };
const spot = findSpot(size.w, size.h); const spot = findSpot(size.w, size.h);
const settings = {}; const settings = {};
@@ -1991,6 +2098,8 @@ function renderLayout(layout) {
// "reset" can simply hand over no tiles. // "reset" can simply hand over no tiles.
const list = Array.isArray(layout) ? layout : (layout?.tiles || []); const list = Array.isArray(layout) ? layout : (layout?.tiles || []);
clearAllTiles(); clearAllTiles();
// Density first: the tiles are clamped and placed against it.
if (!Array.isArray(layout)) setGridCols(layout?.gridCols ?? GRID_DEFAULT, { save: false });
tiles = list.map(sanitizeTile); tiles = list.map(sanitizeTile);
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false }); if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile); for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile);
@@ -2057,6 +2166,13 @@ function renderAddMenu(menu) {
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => { wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
menu.innerHTML = ''; menu.innerHTML = '';
// Density first: the one thing in here that is not a layout but the board
// the layouts are arranged on.
menu.appendChild(gridStepper());
const stepperDivider = document.createElement('div');
stepperDivider.className = 'menu-divider';
menu.appendChild(stepperDivider);
menu.appendChild(menuItem('Save as named layout…', async () => { menu.appendChild(menuItem('Save as named layout…', async () => {
menu.hidden = true; menu.hidden = true;
if (!tiles.length) { if (!tiles.length) {
@@ -2088,7 +2204,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
const res = await fetch('/api/layouts/' + encodeURIComponent(name), { const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: { autoHideHeads, tiles } }), body: JSON.stringify({ layout: { autoHideHeads, gridCols: COLS, tiles } }),
}); });
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')'); if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
@@ -2136,15 +2252,17 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
// does not live-link browsers together. // does not live-link browsers together.
let outgoing = null; let outgoing = null;
try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ } try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ }
const gridBefore = COLS;
renderLayout(body.layout); renderLayout(body.layout);
saveLayout(); saveLayout();
try { if (outgoing) localStorage.setItem(LS_LAYOUT_PREVIOUS, outgoing); } catch { /* fine */ } try { if (outgoing) localStorage.setItem(LS_LAYOUT_PREVIOUS, outgoing); } catch { /* fine */ }
const unshown = unshownTileNames(); const unshown = unshownTileNames();
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`; const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
const note = unshown.length const note = unshown.length
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now` ? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
: ''; : '';
toast(`Loaded “${l.name}”: ${count}${note}`, false, { toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
ms: unshown.length ? 10000 : 7000, ms: unshown.length ? 10000 : 7000,
action: outgoing ? { action: outgoing ? {
label: 'Undo', label: 'Undo',
@@ -2300,7 +2418,8 @@ function applyFullscreenFit() {
} }
function clearFullscreenFit() { function clearFullscreenFit() {
grid.style.gridTemplateColumns = ''; // Back to this browser's density, not the stylesheet's twelve.
grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`;
grid.style.gridAutoRows = ''; grid.style.gridAutoRows = '';
gridwrap.classList.remove('no-scroll'); gridwrap.classList.remove('no-scroll');
for (const tile of tiles) { for (const tile of tiles) {
+30
View File
@@ -187,6 +187,36 @@ body.menu-hidden #topbar {
white-space: normal; white-space: normal;
} }
.menu-divider { height: 1px; background: var(--border); margin: 5px 4px; } .menu-divider { height: 1px; background: var(--border); margin: 5px 4px; }
/* the Layouts menu's grid-density row: Grid Size − 12 + */
.menu-stepper {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px 8px;
font-size: 13px;
}
.menu-stepper-label { flex: 1; }
.menu-stepper-btn {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
cursor: pointer;
}
.menu-stepper-btn:hover { border-color: var(--muted); }
.menu-stepper-btn[disabled] { opacity: 0.35; cursor: default; }
.menu-stepper-btn svg { width: 12px; height: 12px; display: block; }
.menu-stepper-value {
min-width: 28px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.menu-title { .menu-title {
padding: 6px 10px 3px; padding: 6px 10px 3px;
color: var(--muted); color: var(--muted);
+4
View File
@@ -1619,6 +1619,7 @@ function previousLayoutPath(name) {
so an old file does not haunt a new client. */ so an old file does not haunt a new client. */
const LAYOUT_VERSION = 2; const LAYOUT_VERSION = 2;
const GRID_STEPS = [8, 10, 12, 16, 20, 24]; // the shell's grid densities
const MAX_LAYOUT_TILES = 100; const MAX_LAYOUT_TILES = 100;
const KNOWN_TILE_FIELDS = new Set(['id', 'module', 'x', 'y', 'w', 'h', 'settings', 'variant', 'title', 'headPinned', 'minSize']); const KNOWN_TILE_FIELDS = new Set(['id', 'module', 'x', 'y', 'w', 'h', 'settings', 'variant', 'title', 'headPinned', 'minSize']);
const RETIRED_TILE_FIELDS = new Set(['headHidden']); const RETIRED_TILE_FIELDS = new Set(['headHidden']);
@@ -1644,6 +1645,9 @@ function sanitizeLayout(raw) {
// Whether tile title bars auto-hide is part of how a dashboard is // Whether tile title bars auto-hide is part of how a dashboard is
// arranged, so it travels with the layout like the tiles themselves. // arranged, so it travels with the layout like the tiles themselves.
autoHideHeads: Boolean(raw?.autoHideHeads), autoHideHeads: Boolean(raw?.autoHideHeads),
// The grid density the layout was arranged on, when it says and it is one
// the shell knows; a browser loading it cuts its board the same way.
...(GRID_STEPS.includes(Number(raw?.gridCols)) ? { gridCols: Number(raw.gridCols) } : {}),
tiles: tiles.map((t) => { tiles: tiles.map((t) => {
const tile = { const tile = {
id: String(t?.id || ''), id: String(t?.id || ''),
+4 -2
View File
@@ -88,7 +88,7 @@ async function waitFor(url, ms = 15000) {
headHidden: true, headHidden: true,
}; };
let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, tiles: [tile] } }) }); let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, gridCols: 16, tiles: [tile] } }) });
assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body)); assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body));
assert.equal(r.body.replaced, false, 'a first save replaces nothing'); assert.equal(r.body.replaced, false, 'a first save replaces nothing');
@@ -97,6 +97,7 @@ async function waitFor(url, ms = 15000) {
const got = r.body.layout; const got = r.body.layout;
assert.equal(got.v, 2, 'the file carries a schema version'); assert.equal(got.v, 2, 'the file carries a schema version');
assert.equal(got.autoHideHeads, true); assert.equal(got.autoHideHeads, true);
assert.equal(got.gridCols, 16, 'grid density travels with the layout');
const t = got.tiles[0]; const t = got.tiles[0];
for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k); for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k);
assert.deepEqual(t.settings, tile.settings, 'settings'); assert.deepEqual(t.settings, tile.settings, 'settings');
@@ -107,7 +108,7 @@ async function waitFor(url, ms = 15000) {
assert.equal('headHidden' in t, false, 'a retired field is dropped'); assert.equal('headHidden' in t, false, 'a retired field is dropped');
// Saving over the name keeps the copy it replaced. // Saving over the name keeps the copy it replaced.
r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [{ ...tile, title: 'Service v2' }] } }) }); r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { gridCols: 13, tiles: [{ ...tile, title: 'Service v2' }] } }) });
assert.equal(r.status, 200); assert.equal(r.status, 200);
assert.equal(r.body.replaced, true, 'the second save reports a replacement'); assert.equal(r.body.replaced, true, 'the second save reports a replacement');
assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept'); assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept');
@@ -125,6 +126,7 @@ async function waitFor(url, ms = 15000) {
assert.equal(r.status, 200); assert.equal(r.status, 200);
r = await api('/api/layouts/Booth%20A'); r = await api('/api/layouts/Booth%20A');
assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again'); assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again');
assert.equal('gridCols' in r.body.layout, false, 'a density the shell does not offer is dropped, not stored');
r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' }); r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' });
assert.equal(r.status, 404, 'nothing to restore is said, not faked'); assert.equal(r.status, 404, 'nothing to restore is said, not faked');