diff --git a/README.md b/README.md index 0c23e5a..9934e2d 100644 --- a/README.md +++ b/README.md @@ -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 the module's default tile. Saving over an existing name asks first, and 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, red = upstream unreachable (hover it for details). Tiles reconnect automatically. diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 9a911f2..e0d9200 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -136,7 +136,7 @@ the app); its settings are kept for a later reinstall. | `client` | yes | Client entry file, loaded as an ES module. | | `server` | no | Server entry file (CommonJS), loaded with `require()`. | | `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. | | `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**. | diff --git a/package.json b/package.json index ba4bb22..b18a3f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proddash", - "version": "1.8.0", + "version": "1.9.0", "private": true, "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "license": "UNLICENSED", diff --git a/public/shell.js b/public/shell.js index 128aa97..0f64e2d 100644 --- a/public/shell.js +++ b/public/shell.js @@ -5,7 +5,18 @@ settings) autosaves to this browser's localStorage — every browser is fully 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 grid = document.getElementById('grid'); @@ -224,7 +235,7 @@ const LS_LAYOUT_PREVIOUS = LS_LAYOUT + '.previous'; function saveLayout() { 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); if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current); localStorage.setItem(LS_LAYOUT, next); @@ -275,6 +286,87 @@ function newId() { /* ── 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 = ''; +const STEP_PLUS_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() { const style = getComputedStyle(grid); const gapRaw = parseFloat(style.columnGap); @@ -297,6 +389,17 @@ function isFree(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. */ function findSpot(w, h) { 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`); 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 floor = minSizeOf({ module: moduleId, minSize: entry?.minSize }); const size = { - w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)), - h: Math.max(1, defaultSize?.h || 3), + w: Math.max(floor.w, 1, Math.min(COLS, Math.round((defaultSize?.w || 4) * scale))), + h: Math.max(floor.h, 1, Math.round((defaultSize?.h || 3) * scale)), }; const spot = findSpot(size.w, size.h); const settings = {}; @@ -1991,6 +2098,8 @@ function renderLayout(layout) { // "reset" can simply hand over no tiles. const list = Array.isArray(layout) ? layout : (layout?.tiles || []); 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); if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false }); 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) => { 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.hidden = true; if (!tiles.length) { @@ -2088,7 +2204,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => { const res = await fetch('/api/layouts/' + encodeURIComponent(name), { method: 'PUT', 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(() => ({})); 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. let outgoing = null; try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ } + const gridBefore = COLS; renderLayout(body.layout); saveLayout(); try { if (outgoing) localStorage.setItem(LS_LAYOUT_PREVIOUS, outgoing); } catch { /* fine */ } const unshown = unshownTileNames(); const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`; + const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : ''; const note = unshown.length ? `, ${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, action: outgoing ? { label: 'Undo', @@ -2300,7 +2418,8 @@ function applyFullscreenFit() { } 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 = ''; gridwrap.classList.remove('no-scroll'); for (const tile of tiles) { diff --git a/public/style.css b/public/style.css index 66d891b..922fc62 100644 --- a/public/style.css +++ b/public/style.css @@ -187,6 +187,36 @@ body.menu-hidden #topbar { white-space: normal; } .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 { padding: 6px 10px 3px; color: var(--muted); diff --git a/server.js b/server.js index 2542d92..ebb66b9 100644 --- a/server.js +++ b/server.js @@ -1619,6 +1619,7 @@ function previousLayoutPath(name) { so an old file does not haunt a new client. */ const LAYOUT_VERSION = 2; +const GRID_STEPS = [8, 10, 12, 16, 20, 24]; // the shell's grid densities const MAX_LAYOUT_TILES = 100; const KNOWN_TILE_FIELDS = new Set(['id', 'module', 'x', 'y', 'w', 'h', 'settings', 'variant', 'title', 'headPinned', 'minSize']); 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 // arranged, so it travels with the layout like the tiles themselves. 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) => { const tile = { id: String(t?.id || ''), diff --git a/tools/layout-roundtrip.test.js b/tools/layout-roundtrip.test.js index efec351..68e2b02 100644 --- a/tools/layout-roundtrip.test.js +++ b/tools/layout-roundtrip.test.js @@ -88,7 +88,7 @@ async function waitFor(url, ms = 15000) { 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.body.replaced, false, 'a first save replaces nothing'); @@ -97,6 +97,7 @@ async function waitFor(url, ms = 15000) { const got = r.body.layout; assert.equal(got.v, 2, 'the file carries a schema version'); assert.equal(got.autoHideHeads, true); + assert.equal(got.gridCols, 16, 'grid density travels with the layout'); 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); 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'); // 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.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'); @@ -125,6 +126,7 @@ async function waitFor(url, ms = 15000) { assert.equal(r.status, 200); r = await api('/api/layouts/Booth%20A'); 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: '{}' }); assert.equal(r.status, 404, 'nothing to restore is said, not faked');