diff --git a/README.md b/README.md
index 0c23e5a..c8a88e7 100644
--- a/README.md
+++ b/README.md
@@ -273,7 +273,12 @@ 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** 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. 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..79de9f2 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,84 @@ 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;
+ only a tile now past the right edge is pulled back in. */
+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) {
+ for (const tile of tiles) {
+ tile.w = Math.max(1, Math.min(COLS, tile.w));
+ tile.x = Math.max(0, Math.min(COLS - tile.w, tile.x));
+ 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 — − 12 columns + — 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';
+ const name = document.createElement('span');
+ name.textContent = 'Grid';
+ const sub = document.createElement('span');
+ sub.className = 'sub';
+ label.append(name, sub);
+ 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 = `${COLS} columns`;
+ sub.textContent = COLS === GRID_DEFAULT ? 'default' : COLS < GRID_DEFAULT ? 'coarser' : 'finer';
+ 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);
@@ -990,10 +1079,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 +2084,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 +2152,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 +2190,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 +2238,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 +2404,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..1eacba1 100644
--- a/public/style.css
+++ b/public/style.css
@@ -187,6 +187,37 @@ body.menu-hidden #topbar {
white-space: normal;
}
.menu-divider { height: 1px; background: var(--border); margin: 5px 4px; }
+
+/* the Layouts menu's grid-density row: − 12 columns + */
+.menu-stepper {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px 8px;
+ font-size: 13px;
+}
+.menu-stepper-label { flex: 1; }
+.menu-stepper-label .sub { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; }
+.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: 86px;
+ 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');