From 1292ba33babac764b12235c3dfded05aeb54e56f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 14:42:15 +0000 Subject: [PATCH 1/3] Keep module config and layouts in a per-machine data directory Connection settings, API keys, enabled flags and named layouts were written to config/ inside the checkout, so a re-clone or reset from main started the server with nothing configured. They now live in a data directory outside the app folder: Windows %APPDATA%\ProdDash macOS ~/Library/Application Support/ProdDash other $XDG_CONFIG_HOME/proddash, else ~/.config/proddash PRODDASH_DATA_DIR overrides the location (a relative path resolves against the app folder, so "config" restores the old spot). The first start after upgrading copies an existing config/modules.json and config/layouts/ into the data directory, so nothing has to be re-entered. If the directory cannot be created or written, the server warns and falls back to config/ rather than refusing to start. A proddash.json in the data directory overrides the checked-in defaults (port, adminPasscode). The startup banner prints the directory in use, /api/admin/state exposes it, and the admin page shows it under the Modules heading. Admin config and layout writes that fail on disk now report the path instead of a generic server error. README, MODULE-GUIDE and CLAUDE.md updated. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- CLAUDE.md | 5 +- README.md | 41 ++++++++++-- docs/MODULE-GUIDE.md | 9 +-- public/admin/admin.css | 12 ++++ public/admin/admin.js | 5 ++ public/admin/index.html | 2 + server.js | 141 ++++++++++++++++++++++++++++++++++------ 7 files changed, 184 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 56f7f2a..f08eb4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,10 @@ Modular production dashboard web app for Waters Church, developed in this repo. The app lives at the repo root and is built: zero-dependency Node server (`server.js`, Node 18+), dashboard shell client in `public/`, admin page at -`/admin`, modules in `modules/`. [SPEC.md](SPEC.md) was the authority for the +`/admin`, modules in `modules/`. Runtime state (module config, named layouts) +is written to a per-machine data directory outside the checkout +(`PRODDASH_DATA_DIR` overrides; see README "Where settings are kept") — never +add code that writes runtime state into the repo folder. [SPEC.md](SPEC.md) was the authority for the initial build; [README.md](README.md) has run/configure/use instructions. ## Building a new module — read docs/MODULE-GUIDE.md first diff --git a/README.md b/README.md index c2b5201..925b752 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ touching the shell (see [docs/MODULE-GUIDE.md](docs/MODULE-GUIDE.md)). that's where module servers proxy ProdCom, poll ProPresenter, and stream live updates over SSE, so browsers never talk to production gear directly (no CORS, and API keys never leave the server). -4. Stores server-wide module config and named layouts under `config/`. +4. Stores server-wide module config and named layouts in a per-machine data + directory outside the app folder, so updating ProdDash never loses them. Each browser arranges its own tiles (add, drag, resize, remove, per-tile settings); the layout autosaves to that browser's `localStorage`. Named @@ -47,16 +48,43 @@ enable/disable) live in the **admin page** — open **Admin** from the dashboard's top bar, or go to `/admin`. Saving re-initialises the module immediately; open tiles reconnect on their own. -Shell settings live in [config/proddash.json](config/proddash.json): +### Where settings are kept + +Everything you enter in the admin page (module connection settings, API keys, +enabled/disabled flags) and every named layout is written to a **data +directory on the machine running the server** — not into the app folder: + +| OS | Data directory | +| --- | --- | +| Windows | `%APPDATA%\ProdDash` (e.g. `C:\Users\\AppData\Roaming\ProdDash`) | +| macOS | `~/Library/Application Support/ProdDash` | +| Linux | `$XDG_CONFIG_HOME/proddash`, else `~/.config/proddash` | + +Because it lives outside the checkout, you can `git pull`, re-clone, or reset +the app folder from `main` and start the new version with everything still +configured. The admin page shows the exact path in use, and the server prints +it at startup (`Settings : …`). + +The directory holds `modules.json` (module config) and `layouts/` (named +layouts), both written by the app itself. It may also hold a `proddash.json` +with this machine's shell settings — the same keys as the checked-in +[config/proddash.json](config/proddash.json), which supplies the defaults: | Key | Meaning | | --- | --- | | `port` | Port ProdDash serves on (default `24500`) | | `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. | -Environment variables `PORT` and `PRODDASH_PASSCODE` override the file. -`config/modules.json` (module config) and `config/layouts/` (named layouts) -are written by the app itself. +Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and +`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path +is taken from the app folder; `PRODDASH_DATA_DIR=config` restores the old +in-repo location). If the default directory can't be created, the server says +so and falls back to the app's `config/` folder. + +**Upgrading from an earlier version:** the first start copies an existing +`config/modules.json` and `config/layouts/` from the app folder into the data +directory, so nothing has to be re-entered. The old files are left in place and +ignored from then on. ## Using the dashboard @@ -130,7 +158,8 @@ ProdDash/ │ ├── clock/ │ ├── prodcom-transcript/ │ └── propresenter-now-next/ (carries its own copy of propresenter-core) -├── config/ proddash.json + state written by the app +├── config/ proddash.json defaults (runtime state lives in the +│ per-machine data directory — see "Configure") ├── docs/MODULE-GUIDE.md how to build and install a module ├── tools/prodcom-mock.js mock ProdCom API for development ├── prodcom-listener/ reference fork — read-only, not served diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 87786db..6741fa1 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -72,8 +72,8 @@ Both schemas map field names to specs: ``` `password` fields are admin-config only in practice: their values are stored -in `config/modules.json`, handed to your *server* entry, and **never sent to -browsers** — not in `/api/modules`, and never echoed back into the admin +in the server's `modules.json` (in the per-machine data directory, see the +README), handed to your *server* entry, and **never sent to browsers** — not in `/api/modules`, and never echoed back into the admin form. Anything upstream that needs the secret must go through your server routes. @@ -105,8 +105,9 @@ web address where protocol or path matter (a page to embed, a webhook). Endpoints belong in admin config, not per-tile settings — connections are server-wide by design. -Admin config lives in `config/modules.json` (written by the admin page — -your module never touches that file). Per-tile settings live inside each +Admin config lives in `modules.json` inside the server's data directory +(written by the admin page — your module never touches that file, and it +survives updates of the app folder). Per-tile settings live inside each browser's layout. ## Client entry (`client.js`) diff --git a/public/admin/admin.css b/public/admin/admin.css index 2e54255..4a7c0e7 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -35,6 +35,18 @@ body { overflow: auto; } margin-bottom: 14px; } +.admin-storage { margin-top: -6px; font-size: 12px; } +.admin-storage code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11.5px; + color: var(--text); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 5px; + padding: 1px 5px; + overflow-wrap: anywhere; +} + .admin-card { background: var(--panel); border: 1px solid var(--border); diff --git a/public/admin/admin.js b/public/admin/admin.js index fdcd56c..9f43bbc 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -382,6 +382,11 @@ async function loadState() { } loginCard.hidden = true; modulesSection.hidden = false; + const storageNote = document.getElementById('storage-note'); + if (state.dataDir) { + document.getElementById('storage-path').textContent = state.dataDir; + storageNote.hidden = false; + } modulesList.innerHTML = ''; liveEls.clear(); for (const mod of state.modules || []) { diff --git a/public/admin/index.html b/public/admin/index.html index 3081a90..c386ec5 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -32,6 +32,8 @@

Server-wide settings, shared by every dashboard. Saving re-initialises the module immediately — open tiles reconnect on their own. Per-tile options (filters, text size) live in each tile's gear menu instead.

+
diff --git a/server.js b/server.js index 8204103..98be267 100644 --- a/server.js +++ b/server.js @@ -4,13 +4,16 @@ * * Shell server: serves the dashboard client from public/, discovers modules * in modules/, mounts each enabled module's server routes under - * /api/modules//, and stores server-side config + named layouts under - * config/. + * /api/modules//, and stores server-side module config + named layouts + * in a per-machine data directory (see DATA_DIR below) so that updating the + * checkout never loses them. * * Zero dependencies — Node built-ins only (Node 18+ for global fetch). - * Configure via config/proddash.json or env vars: - * PORT (default: value in proddash.json, else 24500) - * PRODDASH_PASSCODE (overrides adminPasscode in proddash.json) + * Configure via config/proddash.json (defaults, in the repo), an optional + * proddash.json in the data directory (this machine's overrides), or env vars: + * PORT (default: value in proddash.json, else 24500) + * PRODDASH_PASSCODE (overrides adminPasscode in proddash.json) + * PRODDASH_DATA_DIR (where module config + layouts are kept; see below) */ 'use strict'; @@ -22,9 +25,54 @@ const crypto = require('crypto'); const ROOT = __dirname; const PUBLIC_DIR = path.join(ROOT, 'public'); -const CONFIG_DIR = path.join(ROOT, 'config'); const MODULES_DIR = path.join(ROOT, 'modules'); -const MODULES_CONFIG_PATH = path.join(CONFIG_DIR, 'modules.json'); +/** Checked-in defaults (proddash.json) — and where runtime state used to live. */ +const REPO_CONFIG_DIR = path.join(ROOT, 'config'); + +/* ── data directory ─────────────────────────────────────────────────── */ + +/** + * Everything the app writes — module config (connection settings, API keys, + * enabled flags) and named layouts — lives in a per-machine data directory + * OUTSIDE the checkout. Pulling, re-cloning or resetting the app folder from + * main therefore never touches it, and nothing has to be re-entered in /admin. + * + * PRODDASH_DATA_DIR overrides the location. A relative path resolves + * against the app folder ("config" = the old in-repo spot). + * default Windows %APPDATA%\ProdDash + * macOS ~/Library/Application Support/ProdDash + * other $XDG_CONFIG_HOME/proddash, else ~/.config/proddash + * + * If the directory cannot be created or written, the server says so and + * falls back to the in-repo config/ folder rather than refusing to start. + */ +function defaultDataDir() { + const home = os.homedir(); + if (process.platform === 'win32') { + return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash'); + } + if (process.platform === 'darwin') { + return path.join(home, 'Library', 'Application Support', 'ProdDash'); + } + return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash'); +} + +function resolveDataDir() { + const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim(); + const dir = fromEnv ? path.resolve(ROOT, fromEnv) : defaultDataDir(); + try { + fs.mkdirSync(dir, { recursive: true }); + fs.accessSync(dir, fs.constants.W_OK); + return dir; + } catch (err) { + console.warn(`[proddash] cannot use data directory ${dir} (${err.message}) — falling back to ${REPO_CONFIG_DIR}`); + return REPO_CONFIG_DIR; + } +} + +const DATA_DIR = resolveDataDir(); +const MODULES_CONFIG_PATH = path.join(DATA_DIR, 'modules.json'); +const LAYOUTS_DIR = path.join(DATA_DIR, 'layouts'); /* ── config files ───────────────────────────────────────────────────── */ @@ -43,7 +91,52 @@ function writeJson(file, value) { fs.renameSync(tmp, file); // atomic-ish: never leave a half-written config } -const shellConfig = readJson(path.join(CONFIG_DIR, 'proddash.json'), {}); +/** + * One-time move-in. Earlier versions kept modules.json and layouts/ inside the + * repo's config/ folder; if the data directory has none yet and those files + * exist, copy them across (the originals are left alone). + */ +function migrateLegacyState() { + if (DATA_DIR === REPO_CONFIG_DIR) return; + const moved = []; + const oldModules = path.join(REPO_CONFIG_DIR, 'modules.json'); + if (!fs.existsSync(MODULES_CONFIG_PATH) && fs.existsSync(oldModules)) { + try { + fs.copyFileSync(oldModules, MODULES_CONFIG_PATH); + moved.push('modules.json'); + } catch (err) { + console.warn(`[proddash] could not copy ${oldModules} into the data directory: ${err.message}`); + } + } + const oldLayouts = path.join(REPO_CONFIG_DIR, 'layouts'); + let oldFiles = []; + try { + oldFiles = fs.readdirSync(oldLayouts).filter((f) => f.endsWith('.json')); + } catch { /* none to move */ } + let haveLayouts = false; + try { + haveLayouts = fs.readdirSync(LAYOUTS_DIR).some((f) => f.endsWith('.json')); + } catch { /* no layouts dir yet */ } + if (oldFiles.length && !haveLayouts) { + try { + fs.mkdirSync(LAYOUTS_DIR, { recursive: true }); + for (const f of oldFiles) fs.copyFileSync(path.join(oldLayouts, f), path.join(LAYOUTS_DIR, f)); + moved.push(`${oldFiles.length} layout${oldFiles.length === 1 ? '' : 's'}`); + } catch (err) { + console.warn(`[proddash] could not copy layouts into the data directory: ${err.message}`); + } + } + if (moved.length) { + console.log(`[proddash] moved ${moved.join(' and ')} from ${REPO_CONFIG_DIR} into ${DATA_DIR}`); + } +} +migrateLegacyState(); + +/** Shell settings: checked-in defaults, then this machine's overrides on top. */ +const shellConfig = { + ...readJson(path.join(REPO_CONFIG_DIR, 'proddash.json'), {}), + ...readJson(path.join(DATA_DIR, 'proddash.json'), {}), +}; const PORT = Number(process.env.PORT || shellConfig.port || 24500); /** Per-module server-wide state: { "": { enabled: bool, config: {…} } } */ @@ -453,6 +546,7 @@ async function handleAdminApi(req, res, urlPath) { return sendJson(res, 200, { authRequired: Boolean(ADMIN_PASSCODE), authed: true, + dataDir: DATA_DIR, modules: [...manifests.keys()].sort().map(adminModuleView), }); } @@ -468,15 +562,19 @@ async function handleAdminApi(req, res, urlPath) { } catch { return sendJson(res, 400, { error: 'Malformed request.' }); } - if (m[2] === 'config') { - applyConfigPatch(id, body.config); - remountModule(id); // config changes take effect without a manual restart - } else { - const enabled = Boolean(body.enabled); - modulesConfig[id] = { ...moduleState(id), enabled }; - writeJson(MODULES_CONFIG_PATH, modulesConfig); - remountModule(id); // mounts when enabled, unmounts (only) otherwise + try { + if (m[2] === 'config') { + applyConfigPatch(id, body.config); + } else { + modulesConfig[id] = { ...moduleState(id), enabled: Boolean(body.enabled) }; + writeJson(MODULES_CONFIG_PATH, modulesConfig); + } + } catch (err) { + return sendJson(res, 500, { error: `Could not save ${MODULES_CONFIG_PATH}: ${err?.message || err}` }); } + // Config changes take effect without a manual restart; a toggle mounts when + // enabled and unmounts (only) otherwise. + remountModule(id); broadcastShellEvent('modules-changed', { id }); return sendJson(res, 200, { ok: true, module: adminModuleView(id) }); } @@ -484,9 +582,7 @@ async function handleAdminApi(req, res, urlPath) { sendJson(res, 404, { error: 'Unknown admin endpoint.' }); } -/* ── named layouts (JSON files under config/layouts/) ───────────────── */ - -const LAYOUTS_DIR = path.join(CONFIG_DIR, 'layouts'); +/* ── named layouts (JSON files under /layouts/) ───────────── */ /** Names double as file names — keep them boring on purpose. */ function layoutNameOk(name) { @@ -564,7 +660,11 @@ async function handleLayoutsApi(req, res, urlPath) { } const layout = sanitizeLayout(body.layout); if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' }); - writeJson(layoutPath(name), layout); + try { + writeJson(layoutPath(name), layout); + } catch (err) { + return sendJson(res, 500, { error: `Could not save ${layoutPath(name)}: ${err?.message || err}` }); + } return sendJson(res, 200, { ok: true, name }); } @@ -824,6 +924,7 @@ server.listen(PORT, '0.0.0.0', () => { console.log(ids.length ? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)')).join(', ') : ' Modules : none installed'); + console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : '')); }); for (const signal of ['SIGINT', 'SIGTERM']) { From 66f45609f0557aba4d326e638ef71ed54463b06c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 21:20:52 +0000 Subject: [PATCH 2/3] Bottom-edge height pill on tiles; fullscreen fit runs every column to the bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiles get a third resize handle: a pill on the bottom edge (hover-only, between the two corner handles) that moves that edge. With nothing directly below, it changes just the tile's height. When other tiles share the edge, the drag trades rows with them so the column keeps its total height — the vertical counterpart of the seam pill. Clamped to every tile's minimum height; blocked by collisions like the corner handles. Fullscreen auto-fit reworked so columns actually reach the bottom of the screen. The old rule only stretched tiles whose bottom edge was within two rows of the viewport bottom, which on a display taller than the browser window the layout was designed in left a blank band below every column. Now the bottom-most tile of each column stretches to the bottom regardless of distance. Short tiles (h <= 2, the clock) are never stretched into a ribbon: under another tile they drop to the bottom edge and the tile above grows into the gap (stopping at anything else below it); alone in their columns they stay put. The fit re-runs from a ResizeObserver on the grid area, so the browser's animated fullscreen transition and the menubar collapse both settle into a correct fit (replaces the resize/transitionend listeners). README documents the pill and the fullscreen behaviour. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- README.md | 19 +++-- public/shell.js | 180 +++++++++++++++++++++++++++++++++++++++-------- public/style.css | 34 +++++++++ 3 files changed, 198 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 925b752..85d1241 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,21 @@ ignored from then on. - **+** — add any enabled module; multiple tiles of the same module are fine (e.g. two transcripts filtered to different channels). -- Drag a tile by its **title bar** to move it; drag the **corner handle** - to resize; **✕** removes it. Tiles sit flush — when two share an edge, - hover the seam and a small grab pill appears; drag it to trade width - between the two tiles (macOS split-view style). +- Drag a tile by its **title bar** to move it; drag either **bottom + corner** to resize; **✕** removes it. Hover a tile and a small pill + appears on its **bottom edge** — drag it to change just the height. If + another tile sits directly below, the pill moves the shared edge instead, + trading rows between the two so the column keeps its total height. Tiles + sit flush — when two share a vertical edge, hover the seam and the same + kind of pill appears; drag it to trade width (macOS split-view style). +- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides, the + occupied columns stretch to the full width, and every column runs to the + bottom of the screen — the last tile in each column is stretched to end + there. A short tile like the clock is never stretched into a ribbon: under + another tile it slides to the bottom edge and the tile above grows into the + gap; alone in its columns it stays as it is. Nothing is saved — the layout + reverts when you leave fullscreen. A layout deeper than the screen keeps + its normal row height and scrolls. - A tile's actions (menus, text size, clear…) live in its **title bar**; the small **notch** on the bar's bottom edge hides/shows the whole bar for a clean wall-display look. diff --git a/public/shell.js b/public/shell.js index d0d10bd..3936ede 100644 --- a/public/shell.js +++ b/public/shell.js @@ -277,6 +277,11 @@ function buildTile(tile) { resizeLeft.className = 'tile-resize-left'; resizeLeft.title = 'Drag to resize'; + // pill on the bottom edge: moves that edge (trading rows with tiles below) + const resizeBottom = document.createElement('div'); + resizeBottom.className = 'tile-resize-bottom'; + resizeBottom.title = 'Drag to adjust height'; + // notch on the title bar's bottom edge: hides/shows the bar const notch = document.createElement('button'); notch.className = 'tile-notch'; @@ -288,7 +293,7 @@ function buildTile(tile) { saveLayout(); }); - el.append(head, body, resize, resizeLeft, notch); + el.append(head, body, resize, resizeLeft, resizeBottom, notch); el.classList.toggle('head-hidden', Boolean(tile.headHidden)); grid.appendChild(el); @@ -299,6 +304,7 @@ function buildTile(tile) { wireDrag(tile, el, head); wireResize(tile, el, resize, false); wireResize(tile, el, resizeLeft, true); + wireBottomResize(tile, el, resizeBottom); mountModule(tile); return entry; @@ -429,6 +435,79 @@ function wireResize(tile, el, handle, fromLeft) { }); } +/* ── drag the bottom edge: height only ──────────────────────────────── */ + +/* The pill on a tile's bottom edge moves that edge. When other tiles sit + directly below it (sharing the edge), the drag trades rows with them — + the column's total height stays the same, like the seam pill trades + columns. With nothing directly below, it simply changes this tile's + height. */ + +function tilesDirectlyBelow(tile) { + return tiles.filter((t) => t !== tile + && t.y === tile.y + tile.h + && t.x < tile.x + tile.w && tile.x < t.x + t.w); +} + +function wireBottomResize(tile, el, handle) { + handle.addEventListener('pointerdown', (ev) => { + if (ev.button !== 0 && ev.pointerType === 'mouse') return; + if (document.body.classList.contains('fullscreen')) return; + ev.preventDefault(); + ev.stopPropagation(); + closeSettingsPopovers(); + handle.setPointerCapture(ev.pointerId); + const m = cellMetrics(); + const sy = ev.clientY; + const startH = tile.h; + const below = tilesDirectlyBelow(tile).map((t) => ({ t, bottom: t.y + t.h })); + const group = [tile.id, ...below.map((b) => b.t.id)]; + const minH = minSizeOf(tile).h; + // the edge can rise until this tile hits its minimum, and drop until the + // shortest neighbour below hits its own + const maxH = below.length + ? Math.min(...below.map((b) => b.bottom - minSizeOf(b.t).h)) - tile.y + : Infinity; + el.classList.add('resizing'); + grid.classList.add('no-dividers'); + let changed = false; + + const onMove = (e) => { + const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap)); + const h = Math.max(minH, Math.min(maxH, startH + dRows)); + if (h === tile.h || h > maxH) return; + const edge = tile.y + h; + const candSelf = { x: tile.x, y: tile.y, w: tile.w, h }; + const candBelow = below.map((b) => ({ x: b.t.x, y: edge, w: b.t.w, h: b.bottom - edge })); + if (!isFreeExcept(candSelf, group) || candBelow.some((c) => !isFreeExcept(c, group))) return; + tile.h = h; + applyRect(tile); + notifyResize(tile); + below.forEach((b, i) => { + b.t.y = candBelow[i].y; + b.t.h = candBelow[i].h; + applyRect(b.t); + notifyResize(b.t); + }); + changed = true; + }; + const onUp = () => { + handle.removeEventListener('pointermove', onMove); + handle.removeEventListener('pointerup', onUp); + handle.removeEventListener('pointercancel', onUp); + el.classList.remove('resizing'); + grid.classList.remove('no-dividers'); + if (changed) { + saveLayout(); + rebuildDividers(); + } + }; + handle.addEventListener('pointermove', onMove); + handle.addEventListener('pointerup', onUp); + handle.addEventListener('pointercancel', onUp); + }); +} + function notifyResize(tile) { const entry = tileEls.get(tile.id); try { @@ -1079,27 +1158,68 @@ try { /* ── fullscreen: auto-fit the layout to the screen ──────────────────── */ /* Fullscreen is a viewing mode. The occupied columns stretch proportionally - to the full width; tiles whose bottom edge lands near the bottom of the - viewport are stretched to end exactly at it; when nothing extends below - the viewport, scrolling is disabled. All of it is visual-only — the saved + to the full width, and every column runs to the bottom of the screen: the + bottom-most tile of each column stretches to end exactly there. A small + tile (the clock) is never stretched into a ribbon — with a tile above it, + it drops to the bottom edge and the tile above grows into the gap; alone + in its columns, it stays exactly as it is. When nothing extends below the + viewport, scrolling is disabled. All of it is visual-only — the saved layout is untouched and everything reverts on exit. */ const fsBtn = document.getElementById('fs-btn'); -/** Rows this close to the viewport bottom count as "near" and get stretched. */ -const FS_NEAR_ROWS = 2; +/** Tiles this short (the clock) are moved, never stretched, in fullscreen. */ +const FS_SMALL_ROWS = 2; let menuHiddenBeforeFs = false; -function fsSmallExempt(tile) { - // A small tile (like the clock) alone in its columns must not become a - // full-height ribbon. - if (tile.h > 2) return false; - return !tiles.some((t) => t !== tile && t.x < tile.x + tile.w && tile.x < t.x + t.w); +function colsOverlap(a, b) { + return a.x < b.x + b.w && b.x < a.x + a.w; +} + +function fsSmall(tile) { + return tile.h <= FS_SMALL_ROWS; +} + +function fsAloneInColumns(tile) { + return !tiles.some((t) => t !== tile && colsOverlap(t, tile)); } function fsBottomMost(tile) { - return !tiles.some((t) => t !== tile - && t.x < tile.x + tile.w && tile.x < t.x + t.w - && t.y >= tile.y + tile.h); + return !tiles.some((t) => t !== tile && colsOverlap(t, tile) && t.y >= tile.y + tile.h); +} + +/** + * Fullscreen row placement for every tile, given R rows on screen: + * { id -> { y, h } }. Only bottom-most tiles change (stretch, or drop when + * small), plus the tile directly above a dropped small one, which grows + * into the space it left — stopping at anything else below it. + */ +function fullscreenRows(R) { + const rows = new Map(tiles.map((t) => [t.id, { y: t.y, h: t.h }])); + const dropped = []; + for (const tile of tiles) { + if (!fsBottomMost(tile)) continue; + if (!fsSmall(tile)) { + rows.get(tile.id).h = R - tile.y; + } else if (!fsAloneInColumns(tile) && R - tile.h > tile.y) { + rows.get(tile.id).y = R - tile.h; + dropped.push(tile); + } + } + for (const small of dropped) { + const gapTop = rows.get(small.id).y; + for (const above of tiles) { + if (above === small || fsSmall(above) || !colsOverlap(above, small)) continue; + if (above.y + above.h !== small.y) continue; // only a tile touching it + let limit = gapTop; + for (const other of tiles) { + if (other === above || other === small || !colsOverlap(above, other)) continue; + const oy = rows.get(other.id).y; + if (oy >= above.y + above.h) limit = Math.min(limit, oy); + } + rows.get(above.id).h = Math.max(above.h, limit - above.y); + } + } + return rows; } function applyFullscreenFit() { @@ -1128,16 +1248,14 @@ function applyFullscreenFit() { const cellH = (wrapH - (R - 1) * m.gap) / R; grid.style.gridAutoRows = cellH + 'px'; + const rows = fullscreenRows(R); for (const tile of tiles) { - applyRect(tile); // reset any previous stretch first - const bottom = tile.y + tile.h; - if (R - bottom <= FS_NEAR_ROWS && fsBottomMost(tile) && !fsSmallExempt(tile)) { - const entry = tileEls.get(tile.id); - if (entry) { - entry.el.style.gridRow = `${tile.y + 1} / span ${R - tile.y}`; - try { entry.instance?.onResize?.(tile.w, R - tile.y); } catch { /* module's problem */ } - } - } + const entry = tileEls.get(tile.id); + if (!entry) continue; + const r = rows.get(tile.id); + entry.el.style.gridColumn = `${tile.x + 1} / span ${tile.w}`; + entry.el.style.gridRow = `${r.y + 1} / span ${r.h}`; + try { entry.instance?.onResize?.(tile.w, r.h); } catch { /* module's problem, not the shell's */ } } } @@ -1174,14 +1292,14 @@ document.addEventListener('fullscreenchange', () => { } }); -window.addEventListener('resize', () => { - if (document.body.classList.contains('fullscreen')) applyFullscreenFit(); -}); - -// the menubar collapse animates, so the grid's final height arrives late -topbar.addEventListener('transitionend', () => { - if (document.body.classList.contains('fullscreen')) applyFullscreenFit(); -}); +// The grid area's size settles late: the browser animates into fullscreen, +// then the menubar collapse animates. Re-fit whenever its size changes. +let fsRefit = 0; +new ResizeObserver(() => { + if (!document.body.classList.contains('fullscreen')) return; + cancelAnimationFrame(fsRefit); + fsRefit = requestAnimationFrame(applyFullscreenFit); +}).observe(gridwrap); /* ── live updates from the admin page ───────────────────────────────── */ diff --git a/public/style.css b/public/style.css index f435d9c..0c0e936 100644 --- a/public/style.css +++ b/public/style.css @@ -411,6 +411,39 @@ body.menu-hidden #topbar { border-radius: 0 0 0 3px; } +/* bottom-edge pill: drag to move the tile's bottom edge (height only); + sits between the two corner handles */ +.tile-resize-bottom { + position: absolute; + left: 24px; + right: 24px; + bottom: 0; + height: 10px; + z-index: 5; + display: flex; + align-items: flex-end; + justify-content: center; + cursor: ns-resize; + touch-action: none; + opacity: 0; + transition: opacity 0.15s ease; +} +.tile-resize-bottom::after { + content: ""; + width: 44px; + max-width: 60%; + height: 5px; + margin-bottom: 2px; + border-radius: 999px; + background: var(--muted); + transition: background 0.15s ease; +} +.tile:hover .tile-resize-bottom { opacity: 0.6; } +.tile .tile-resize-bottom:hover, +.tile.resizing .tile-resize-bottom { opacity: 1; } +.tile .tile-resize-bottom:hover::after, +.tile.resizing .tile-resize-bottom::after { background: var(--text); } + /* ── seam divider between adjacent tiles ───────────────────────────── */ /* When two tiles share a vertical edge, an invisible strip sits on the seam; @@ -452,6 +485,7 @@ body.menu-hidden #topbar { editing affordances (corner handles, seam pills) are parked. */ body.fullscreen .tile-resize, body.fullscreen .tile-resize-left, +body.fullscreen .tile-resize-bottom, body.fullscreen .tile-divider { display: none; } body.fullscreen #grid { padding-bottom: 0; } #gridwrap.no-scroll { overflow-y: hidden; } From 483c353843bf9e89352dee38533da177add9b8d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 22:02:43 +0000 Subject: [PATCH 3/3] Menubar tab moves under the brand in accent green; Admin hidden in fullscreen The menu bar's show/hide tab was centred on the screen, exactly where a centred tile's own title-bar tab lands once both bars are hidden: two identical tabs on top of each other, with the tile's unreachable. The tab now sits at the left under the ProdDash brand and wears the accent colour, so it never coincides with a tile tab and reads unmistakably as the dashboard's. To keep it off a top-row tile's status dot and title it straddles the bar's bottom edge while the bar is shown (mostly on the bar, a short lip below) and becomes a slim tab at the very top edge when the bar is hidden. The Admin link is hidden in fullscreen, including when the menu bar is brought back there. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- README.md | 9 ++++++--- public/index.html | 2 +- public/style.css | 29 +++++++++++++++++------------ 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 85d1241..3cd53b3 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,9 @@ ignored from then on. trading rows between the two so the column keeps its total height. Tiles sit flush — when two share a vertical edge, hover the seam and the same kind of pill appears; drag it to trade width (macOS split-view style). -- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides, the - occupied columns stretch to the full width, and every column runs to the +- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its + green tab brings it back, minus the Admin button), the occupied columns + stretch to the full width, and every column runs to the bottom of the screen — the last tile in each column is stretched to end there. A short tile like the clock is never stretched into a ribbon: under another tile it slides to the bottom edge and the tile above grows into the @@ -107,7 +108,9 @@ ignored from then on. its normal row height and scrolls. - A tile's actions (menus, text size, clear…) live in its **title bar**; the small **notch** on the bar's bottom edge hides/shows the whole bar - for a clean wall-display look. + for a clean wall-display look. The menu bar has the same kind of tab — + the green one under the ProdDash name, kept at the left so it never sits + on top of a tile's own tab. - The **gear** on a tile holds per-tile settings (filters, text size, display options) — these belong to this browser's layout, not the server. - The **grid icon** — save the current arrangement to the server under a diff --git a/public/index.html b/public/index.html index eab549b..35f8f08 100644 --- a/public/index.html +++ b/public/index.html @@ -42,7 +42,7 @@ - + diff --git a/public/style.css b/public/style.css index 0c0e936..77a5b71 100644 --- a/public/style.css +++ b/public/style.css @@ -54,25 +54,30 @@ body.menu-hidden #topbar { overflow: hidden; } -/* the menubar's own show/hide tab, same as the tile notches */ +/* The menubar's own show/hide tab. It sits under the ProdDash brand at the + left — not screen-centre, where a tile's own (centred) tab would land + right under it once both bars are hidden — and wears the accent colour + so it never reads as a tile tab. */ #topbar-notch { position: fixed; - top: var(--topbar-h, 46px); - left: 50%; - transform: translateX(-50%); + /* straddles the bar's bottom edge — mostly on the bar, a short lip below + it — so it never covers a top-row tile's status dot or title */ + top: calc(var(--topbar-h, 46px) - 8px); + left: 12px; width: 54px; height: 14px; padding: 0; - background: var(--panel); - border: 1px solid var(--border); - border-top: none; + background: var(--accent); + border: none; border-radius: 0 0 10px 10px; cursor: pointer; z-index: 60; - opacity: 0.35; - transition: opacity 0.15s ease, top 0.22s ease; + opacity: 0.55; + transition: opacity 0.15s ease, top 0.22s ease, height 0.22s ease; } #topbar-notch:hover { opacity: 1; } +/* bar hidden: a slim tab at the very top edge, clear of a top-row tile's dot and title */ +body.menu-hidden #topbar-notch { top: 0; height: 10px; } #topbar-notch::after { content: ""; display: block; @@ -80,9 +85,8 @@ body.menu-hidden #topbar { height: 3px; margin: 4px auto 0; border-radius: 999px; - background: var(--muted); + background: var(--bg); } -#topbar-notch:hover::after { background: var(--text); } #brand { font-weight: 800; @@ -486,7 +490,8 @@ body.menu-hidden #topbar { body.fullscreen .tile-resize, body.fullscreen .tile-resize-left, body.fullscreen .tile-resize-bottom, -body.fullscreen .tile-divider { display: none; } +body.fullscreen .tile-divider, +body.fullscreen #admin-link { display: none; } body.fullscreen #grid { padding-bottom: 0; } #gridwrap.no-scroll { overflow-y: hidden; }