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

Twelve columns fit a booth monitor and nothing else well: on a 4K wall a
three-wide tile is nearly a thousand pixels, and on a small screen four tiles
across is the most the grid allows. A stepper in the Layouts menu now cuts
the board into 8, 10, 12, 16, 20 or 24 columns. Row height follows the
column width, so cells keep their shape and a 3×2 tile is still a 3×2 tile,
only smaller — finer lets tiles be smaller so more fit, coarser suits a
screen read close up.

It is this browser's choice, not the server's: two displays at one location
can want different densities, so it saves with the layout — the local one
and a named one on the server — and a named layout comes back at the density
it was arranged on, which the load message says. An older layout with no
density is the default twelve.

Tiles keep their grid units when the density changes; the board simply gets
denser or coarser around them, and a tile now past the right edge is pulled
in. A module's minSize is enforced in cells as declared, so on a finer grid a
tile can be smaller than it could at twelve, which is the point; a new tile's
defaultSize is scaled to the density so it lands at about the size its author
meant. Leaving fullscreen goes back to the chosen density rather than the
stylesheet's twelve — that reset predates any notion of a chosen density.

Shell 1.9.0. The round-trip test carries gridCols and drops a value off the
scale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-22 10:19:49 -04:00
co-authored by Claude Fable 5.1
parent 5107b06f8c
commit 2af7ef27ba
7 changed files with 159 additions and 12 deletions
+112 -7
View File
@@ -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 = '<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 — − 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) {