diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 5497829..699bfab 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -92,12 +92,13 @@ the app); its settings are kept for a later reinstall. | `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. | | `description` | no | Shown in the admin page / picker. | | `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. | +| `provides` | no | Capabilities other modules can consume — `["timers"]` today. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules**. | | `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. | | `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). | +| `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**. | Both schemas map field names to specs: @@ -115,8 +116,13 @@ Both schemas map field names to specs: } ``` -`boolean` renders as a checkbox; `switch` is the same true/false value shown -as a toggle (use it for a feature on/off that other fields depend on). +`boolean` and `switch` are the same true/false value. In a tile's gear popover +both render as a switch; in the admin page `switch` is the toggle and +`boolean` the plainer checkbox (use `switch` for a feature on/off that other +fields depend on). **Label a switch with the noun of what it turns on** — +"Seconds", "Top bar", "Section headers" — never "Show seconds": on means +shown or enabled, off means hidden or disabled, and the label shouldn't say +so twice. `color` renders a swatch picker and stores a `#rrggbb` string (give it a hex default; the tile applies it as a CSS custom property — see `pco-plan`). @@ -141,7 +147,15 @@ the group name: still empty. Omit it and the group is a plain, always-open block. - `columns`: 2–4 lays the group's fields out in a grid — the way to keep a long checklist of booleans compact. -- `help`: a line of guidance at the top of the group. +- `help`: guidance for the whole group. The admin page shows it as an + ⓘ beside the group title (click or hover to read), so a group can carry a + paragraph without pushing its fields down the page. Say where a value comes + from, not what a field is. +- `toggle`: the key of a `switch` field in this group. It renders in the + group's title row, at the right, as the group's on/off; the rest of the group + dims while it is off, and the field is not repeated in the body. This is how + an integration is disabled without unmounting the module — ProPresenter + timers, say, on a Sunday when ProPresenter isn't running. `help` on a field puts one line of guidance under its control — allowed placeholders, where a value comes from. Keep labels short and put the @@ -341,10 +355,13 @@ own in `stop()` anyway. - **Any number of instances.** Two transcript tiles with different filters is a feature. Keep all state inside `create()`'s closure — no module-level mutable state in `client.js`. -- **Remounts are routine.** When the user edits the tile's gear settings the - shell calls `stop()` and starts a fresh instance. When admin config - changes, the shell calls your `onConfigChange(cfg)` if you export one, - otherwise it remounts you. Either way you must come back cleanly. +- **Remounts are routine.** Gear settings apply as the user changes them — + there is no Apply button — so each change calls `stop()` and starts a fresh + instance (typing is debounced, and an unchanged value remounts nothing). + Starting cheaply matters: hold connections and timers in the closure and + tear them down in `stop()`. When admin config changes, the shell calls your + `onConfigChange(cfg)` if you export one, otherwise it remounts you. Either + way you must come back cleanly. - **The tile can be removed at any moment.** `stop()` is your only notice. ### Rules for the client @@ -362,7 +379,11 @@ own in `stop()` anyway. palette: `--bg` (page), `--panel` (tile), `--border`, `--text`, `--muted`, `--accent`, `--danger`, `--warn`. Derive tints with `color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes - every module look native on the dark, high-contrast booth theme. + every module look native — and what makes the dashboard's **themes** + (Admin → Theme) reach into every tile: a theme swaps those variables and + nothing else. A colour a tile is configured with (a `color` instance + setting, applied as a custom property on the tile) is set closer than the + theme and wins over it, by design. 3. **No external network calls from the client.** Browsers talk only to ProdDash. Anything upstream (your device, service, API) goes through your @@ -382,7 +403,7 @@ ethos; Node 18+ so global `fetch` is available). Exports either or both of: ```js module.exports = { - init({ config, log }) { + init({ config, log, shell }) { // Called on startup / re-init, BEFORE routes(). Start your upstream // connection here. Return a handle: return { @@ -391,7 +412,7 @@ module.exports = { }; }, - routes({ config, log }) { + routes({ config, log, shell }) { // Route table, mounted under /api/modules// return { 'GET /state': (req, res) => { … }, @@ -407,6 +428,9 @@ module.exports = { - `config` is your admin config: `configSchema` defaults overlaid with what the admin page saved — **including password fields**. - `log(...)` prefixes output with your module id. +- `shell.port` is the port ProdDash is serving on — what you need to read + another module's routes over loopback (`http://127.0.0.1:${shell.port}/api/ + modules//…`). Never guess it. - Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is the matched remainder (leading `/` included) and `req.search` is the query string (with `?`, or `''`) — so a proxy rebuilds the upstream path as @@ -493,6 +517,71 @@ per-client `res.write` in try/catch (drop the client on failure). The `propresenter-now-next` module is the reference implementation of this pattern. +## Working with other modules + +Modules don't import each other. When one needs what another knows, it reads +that module's **own server routes over loopback**, exactly as a browser would: +`http://127.0.0.1:${shell.port}/api/modules//…`. The PCO Plan module +follows ProPresenter this way — it reads `propresenter-now-next`'s `/stream` +rather than talking to ProPresenter a second time. A consumer must survive the +provider being disabled, uninstalled, or restarting mid-service (404s and +dropped connections are normal; retry with backoff and say so in `health()`). + +### Offering timers to the Timers module + +The Timers module shows timers from anywhere: ProPresenter, LTC, its own +clock, and any module that offers some. A module offers timers by: + +1. Declaring it — `"provides": ["timers"]` in `module.json`. The shell + forwards `provides` in `/api/modules`, which is how the Timers module finds + providers without a list anyone maintains. +2. Serving `GET /timers` from its server routes, returning a snapshot: + +```json +{ + "source": "pco-plan", + "label": "PCO Plan", + "updatedAt": 1757800000000, + "timers": [ + { + "id": "service", + "label": "Service", + "kind": "elapsed", + "state": "running", + "startedAt": 1757799000000, + "elapsedMs": 1000000, + "remainingMs": null, + "targetMs": 4500000, + "status": { "text": "Welcome & Announcements", "tone": "ok" }, + "detail": "Item 3 of 12" + } + ] +} +``` + +| Field | Meaning | +| --- | --- | +| `source`, `label` | Your module id, and the heading the Timers module puts over your timers. | +| `updatedAt` | ms since epoch when this snapshot was taken. Every duration below is *as of this instant*. | +| `id` | Stable within your module — the Timers module keys tile settings (shown/hidden, colours) on `source` + `id`. | +| `kind` | `elapsed` (counts up), `countdown` (counts down to zero), `clock` (a time of day; put the ISO time in `status.text`). | +| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). | +| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. | +| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. | +| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. | +| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. | +| `detail` | Optional second line. | + +The consumer derives the displayed time locally from `startedAt`/`elapsedMs` +and its own clock, so a snapshot every couple of seconds is plenty — `/timers` +must be cheap to answer (serve from state you already hold; never call +upstream to answer it). Optionally also serve `GET /timers/stream` as SSE +(`event: timers`, `data:` the same object, on every change plus a heartbeat +comment every 15 s); the consumer uses the stream when it exists and polls +`/timers` otherwise. Only offer timers a production tech would want on a +wall — the Timers module lets them hide any of them per tile, but every entry +you add is a decision someone has to make. + ## Complete minimal example A working clock — copy-paste this as your starter. (A slightly fancier diff --git a/modules/clock/module.json b/modules/clock/module.json index f95087f..d90051c 100644 --- a/modules/clock/module.json +++ b/modules/clock/module.json @@ -1,9 +1,8 @@ { "id": "clock", "name": "Clock", - "version": "1.0.0", + "version": "1.0.1", "proddash": ">=1.1.0", - "description": "Big booth clock — the smallest possible ProdDash module", "client": "client.js", "style": "style.css", "minSize": { "w": 2, "h": 1 }, @@ -18,7 +17,7 @@ { "value": "12h", "label": "12-hour" } ] }, - "showSeconds": { "type": "boolean", "label": "Show seconds", "default": true }, - "showDate": { "type": "boolean", "label": "Show date", "default": false } + "showSeconds": { "type": "boolean", "label": "Seconds", "default": true }, + "showDate": { "type": "boolean", "label": "Date", "default": false } } } diff --git a/package.json b/package.json index 15fe326..ae778d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proddash", - "version": "1.3.0", + "version": "1.4.0", "private": true, "description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client", "license": "UNLICENSED", diff --git a/public/index.html b/public/index.html index ccffe69..35f8f08 100644 --- a/public/index.html +++ b/public/index.html @@ -15,8 +15,7 @@
- - + ProdDash @@ -51,7 +50,7 @@
- +
diff --git a/public/shell.js b/public/shell.js index 5a8fc98..3b2e20c 100644 --- a/public/shell.js +++ b/public/shell.js @@ -288,11 +288,24 @@ function buildTile(tile) { notch.title = 'Hide / show the title bar'; notch.addEventListener('click', (e) => { e.stopPropagation(); + // In fullscreen the bars are all folded away for watching; the notch + // peeks one open for a moment without touching what this tile saved. + if (document.body.classList.contains('fullscreen')) { + peekHead(tile, el, !el.classList.contains('head-peek')); + return; + } tile.headHidden = !tile.headHidden; el.classList.toggle('head-hidden', tile.headHidden); saveLayout(); }); + // A peeked title bar stays while the pointer is on its tile, and starts + // counting down as soon as it leaves. + el.addEventListener('pointerenter', () => clearPeekTimer(tile.id)); + el.addEventListener('pointerleave', () => { + if (el.classList.contains('head-peek')) schedulePeekHide(tile.id, el); + }); + el.append(head, body, resize, resizeLeft, resizeBottom, notch); el.classList.toggle('head-hidden', Boolean(tile.headHidden)); grid.appendChild(el); @@ -518,13 +531,22 @@ function notifyResize(tile) { /* ── seam dividers between adjacent tiles ───────────────────────────── */ /* Wherever two tiles share a vertical edge, a divider sits on the seam. - Dragging its pill moves the shared edge, trading width between the two - tiles — their combined span never changes, so nothing else can collide - except via the explicit check below (a third tile can sit behind the - seam for part of its height). */ + Dragging one moves that column boundary wherever it appears: every pair of + tiles meeting at the same x, in every row, moves with it, so a column of + tiles keeps its edge as one line rather than drifting row by row. Hovering + a seam lights up the others that travel with it. + + Each pair trades width within its own span — the pair's combined span never + changes — so nothing else can collide except via the explicit check below + (a third tile can sit behind the seam for part of its height). A row where + one tile simply spans the boundary has no seam there and stays put. */ + +/** Live dividers, so a drag can reposition all of them as the tiles move. */ +const dividers = []; function rebuildDividers() { grid.querySelectorAll('.tile-divider').forEach((el) => el.remove()); + dividers.length = 0; for (const L of tiles) { for (const R of tiles) { if (L === R || L.x + L.w !== R.x) continue; @@ -536,10 +558,24 @@ function rebuildDividers() { } } +/** Every pair of tiles that meets at this column boundary, in any row. */ +function seamPairsAt(boundary) { + const pairs = []; + for (const L of tiles) { + if (L.x + L.w !== boundary) continue; + for (const R of tiles) { + if (R === L || R.x !== boundary) continue; + if (Math.min(L.y + L.h, R.y + R.h) <= Math.max(L.y, R.y)) continue; + pairs.push({ L, R }); + } + } + return pairs; +} + function buildDivider(L, R, top, bottom) { const el = document.createElement('div'); el.className = 'tile-divider'; - el.title = 'Drag to resize'; + el.title = 'Drag to resize this column'; const place = () => { const m = cellMetrics(); @@ -550,43 +586,72 @@ function buildDivider(L, R, top, bottom) { }; place(); + const boundary = () => L.x + L.w; + const groupEls = () => dividers.filter((d) => d.boundary() === boundary()).map((d) => d.el); + const unlink = () => { for (const d of dividers) d.el.classList.remove('linked'); }; + + // Before anything is dragged, show which seams travel with this one. + el.addEventListener('pointerenter', () => { + if (document.body.classList.contains('fullscreen')) return; + for (const other of groupEls()) other.classList.add('linked'); + }); + el.addEventListener('pointerleave', unlink); + el.addEventListener('pointerdown', (ev) => { if (ev.button !== 0 && ev.pointerType === 'mouse') return; if (document.body.classList.contains('fullscreen')) return; ev.preventDefault(); closeSettingsPopovers(); el.setPointerCapture(ev.pointerId); - el.classList.add('active'); + const m = cellMetrics(); const sx = ev.clientX; - const startBoundary = L.x + L.w; - const rightEdge = R.x + R.w; // fixed for the whole drag - const minB = L.x + minSizeOf(L).w; - const maxB = rightEdge - minSizeOf(R).w; + const startBoundary = boundary(); + // Every row that has a seam here moves with it. Each pair's outer edges + // are fixed for the whole drag, so each row only trades width internally. + const parts = seamPairsAt(startBoundary).map(({ L: a, R: b }) => ({ + L: a, R: b, leftX: a.x, rightEdge: b.x + b.w, + })); + if (!parts.length) return; + const moving = parts.flatMap((p) => [p.L.id, p.R.id]); + // The whole column stops at whichever row runs out of room first. + const minB = Math.max(...parts.map((p) => p.leftX + minSizeOf(p.L).w)); + const maxB = Math.min(...parts.map((p) => p.rightEdge - minSizeOf(p.R).w)); + const group = groupEls(); + for (const other of group) other.classList.add('active'); let changed = false; const onMove = (e) => { const b = Math.max(minB, Math.min(maxB, startBoundary + Math.round((e.clientX - sx) / (m.cw + m.gap)))); - if (b === L.x + L.w) return; - const candL = { x: L.x, y: L.y, w: b - L.x, h: L.h }; - const candR = { x: b, y: R.y, w: rightEdge - b, h: R.h }; - if (!isFreeExcept(candL, [L.id, R.id]) || !isFreeExcept(candR, [L.id, R.id])) return; - L.w = candL.w; - R.x = candR.x; - R.w = candR.w; - applyRect(L); - applyRect(R); - notifyResize(L); - notifyResize(R); - place(); + if (b === boundary()) return; + const next = parts.map((p) => ({ + p, + candL: { x: p.leftX, y: p.L.y, w: b - p.leftX, h: p.L.h }, + candR: { x: b, y: p.R.y, w: p.rightEdge - b, h: p.R.h }, + })); + // All rows move or none do — a column edge that bends is worse than one + // that won't budge. + if (next.some(({ candL, candR }) => + !isFreeExcept(candL, moving) || !isFreeExcept(candR, moving))) return; + for (const { p, candL, candR } of next) { + p.L.w = candL.w; + p.R.x = candR.x; + p.R.w = candR.w; + applyRect(p.L); + applyRect(p.R); + notifyResize(p.L); + notifyResize(p.R); + } + for (const d of dividers) d.place(); changed = true; }; const onUp = () => { el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerup', onUp); el.removeEventListener('pointercancel', onUp); - el.classList.remove('active'); + for (const other of group) other.classList.remove('active'); + unlink(); if (changed) saveLayout(); rebuildDividers(); // adjacencies may have changed }; @@ -595,6 +660,7 @@ function buildDivider(L, R, top, bottom) { el.addEventListener('pointercancel', onUp); }); + dividers.push({ el, place, boundary }); grid.appendChild(el); } @@ -634,6 +700,7 @@ function addTile(moduleId, entry = null) { function removeTile(id) { const idx = tiles.findIndex((t) => t.id === id); if (idx < 0) return; + clearPeekTimer(id); unmountModule(tiles[idx]); tiles.splice(idx, 1); const entry = tileEls.get(id); @@ -928,7 +995,10 @@ function colorHex(value, fallback) { } function closeSettingsPopovers() { - document.querySelectorAll('.tile-settings').forEach((p) => p.remove()); + document.querySelectorAll('.tile-settings').forEach((p) => { + p._cleanup?.(); + p.remove(); + }); } function toggleSettingsPopover(tile, el) { @@ -942,6 +1012,34 @@ function toggleSettingsPopover(tile, el) { pop.className = 'tile-settings'; const inputs = new Map(); + /* These are display choices — a text size, a filter, a colour — so they + apply as they are changed and save with the layout. There is nothing to + confirm, so there is no Apply. Typing is debounced because applying + means remounting the module, which shouldn't happen per keystroke. */ + let applyTimer = null; + const commit = (delay) => { + clearTimeout(applyTimer); + applyTimer = setTimeout(() => { + applyTimer = null; + // Only when something really moved: a text field fires `input` and then + // `change` on the way out, and a module shouldn't be torn down twice + // over for one edit. + let changed = false; + for (const [key, read] of inputs) { + const value = read(); + if (tile.settings[key] === value) continue; + tile.settings[key] = value; + changed = true; + } + if (!changed) return; + saveLayout(); + remountTile(tile); + }, delay); + }; + const live = (input, event = 'change', delay = 0) => { + input.addEventListener(event, () => commit(delay)); + }; + // Fields render in schema order; a field's `group` starts a labeled // subsection that consecutive same-group fields share, and the manifest's // optional `instanceGroups[name]` adds { collapsed, columns, help } — @@ -999,6 +1097,7 @@ function toggleSettingsPopover(tile, el) { input.checked = Boolean(current); field.append(input, document.createTextNode(label)); inputs.set(key, () => input.checked); + live(input); } else if (type === 'select' && Array.isArray(spec.options)) { const span = document.createElement('span'); span.textContent = label; @@ -1012,6 +1111,7 @@ function toggleSettingsPopover(tile, el) { select.value = String(current ?? ''); field.append(span, select); inputs.set(key, () => select.value); + live(select); } else if (type === 'color') { // A swatch picker; the value is always a #rrggbb string. field.classList.add('color-field'); @@ -1022,6 +1122,7 @@ function toggleSettingsPopover(tile, el) { input.value = colorHex(current, colorHex(spec?.default, '#2ee59a')); field.append(span, input); inputs.set(key, () => input.value); + live(input, 'input', 150); // dragging the picker fires continuously } else { const span = document.createElement('span'); span.textContent = label; @@ -1030,6 +1131,8 @@ function toggleSettingsPopover(tile, el) { input.value = current === undefined || current === null ? '' : String(current); field.append(span, input); inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value)); + live(input, 'input', 400); // mid-word is not the moment to remount + live(input, 'change'); // …but blur or Enter is } if (spec?.help) { const help = document.createElement('small'); @@ -1040,26 +1143,17 @@ function toggleSettingsPopover(tile, el) { container.appendChild(field); } - const actions = document.createElement('div'); - actions.className = 'actions'; - actions.style.display = 'flex'; - actions.style.justifyContent = 'flex-end'; - actions.style.gap = '8px'; - const cancel = document.createElement('button'); - cancel.className = 'btn'; - cancel.textContent = 'Cancel'; - cancel.addEventListener('click', () => pop.remove()); - const apply = document.createElement('button'); - apply.className = 'btn primary'; - apply.textContent = 'Apply'; - apply.addEventListener('click', () => { - for (const [key, read] of inputs) tile.settings[key] = read(); - pop.remove(); - saveLayout(); - remountTile(tile); - }); - actions.append(cancel, apply); - pop.appendChild(actions); + // Nothing to press: click away, press Escape, or click the gear again. + // (The gear's own click stops propagating, so it toggles rather than + // closing and reopening.) + const closeOnClick = (e) => { if (!pop.contains(e.target)) closeSettingsPopovers(); }; + const closeOnEscape = (e) => { if (e.key === 'Escape') closeSettingsPopovers(); }; + pop._cleanup = () => { + document.removeEventListener('click', closeOnClick); + document.removeEventListener('keydown', closeOnEscape); + }; + document.addEventListener('click', closeOnClick); + document.addEventListener('keydown', closeOnEscape); pop.addEventListener('pointerdown', (e) => e.stopPropagation()); el.appendChild(pop); @@ -1222,6 +1316,41 @@ try { if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false }); } catch { /* fine */ } +/* ── fullscreen: peeking at a tile's title bar ──────────────────────── */ + +/** How long a peeked title bar stays after the pointer leaves its tile. */ +const PEEK_HIDE_MS = 10000; +const peekTimers = new Map(); // tile id → timeout + +function clearPeekTimer(id) { + const timer = peekTimers.get(id); + if (timer === undefined) return; + clearTimeout(timer); + peekTimers.delete(id); +} + +function schedulePeekHide(id, el) { + clearPeekTimer(id); + peekTimers.set(id, setTimeout(() => { + peekTimers.delete(id); + el.classList.remove('head-peek'); + }, PEEK_HIDE_MS)); +} + +function peekHead(tile, el, on) { + el.classList.toggle('head-peek', on); + clearPeekTimer(tile.id); + // Opened from a click the pointer is on the tile, so the countdown waits + // for it to leave — unless it already has (a keyboard or touch open). + if (on && !el.matches(':hover')) schedulePeekHide(tile.id, el); +} + +/** Back to a plain screen: no peeks left open, no timers left running. */ +function clearPeeks() { + for (const id of [...peekTimers.keys()]) clearPeekTimer(id); + document.querySelectorAll('.tile.head-peek').forEach((el) => el.classList.remove('head-peek')); +} + /* ── fullscreen: auto-fit the layout to the screen ──────────────────── */ /* Fullscreen is a viewing mode. The occupied columns stretch proportionally @@ -1355,6 +1484,7 @@ document.addEventListener('fullscreenchange', () => { requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit)); } else { setMenuHidden(menuHiddenBeforeFs, { persist: false }); + clearPeeks(); clearFullscreenFit(); } }); diff --git a/public/style.css b/public/style.css index 2ff8649..85df025 100644 --- a/public/style.css +++ b/public/style.css @@ -54,57 +54,43 @@ body.menu-hidden #topbar { overflow: hidden; } -/* The menubar's show/hide handle IS the logo: a translucent accent pill - wrapped around "ProdDash" at the left of the bar, "Dash" cut out in the - background colour. It sits at the left — never screen-centre, where a - tile's own (centred) tab lands once both bars are hidden. When the bar - hides, the pill shrinks to a slim lip at the very top edge, clear of a - top-row tile's status dot and title. */ -.brand-spacer { visibility: hidden; } - +/* The bar's show/hide handle: a small accent lip on its bottom edge, the + same idea as a tile's notch. It sits at the left — never screen-centre, + where a tile's own (centred) tab lands once both bars are hidden — and + grows under the pointer so it is easy to hit without being loud at rest. + It used to be the ProdDash wordmark on a pill hanging past the bar, which + read as a step cut out of the bar, and animating its font-size on the way + out was the jerkiest thing on the page in Safari. Everything here moves by + transform, opacity and its own fixed size: no text metrics, no reflow. */ #topbar-notch { position: fixed; - /* a tab hung from the top of the bar: square top, rounded bottom, hanging - 8px past the bar's bottom edge; the logo sits centred in the bar part */ top: 0; - left: 2px; /* the logo text starts at the bar's 12px padding; the tab adds 10px each side */ - height: calc(var(--topbar-h, 46px) + 8px); - min-width: 60px; - padding: 0 10px; - font: inherit; - font-weight: 800; - font-size: 15px; - letter-spacing: 0.02em; - line-height: var(--topbar-h, 46px); - color: var(--text); - background: color-mix(in srgb, var(--accent) 45%, transparent); + left: 10px; + width: 28px; + height: 5px; + padding: 0; border: none; - border-radius: 0 0 12px 12px; + border-radius: 0 0 5px 5px; + background: var(--accent); + opacity: 0.5; cursor: pointer; z-index: 60; - transition: height 0.22s ease, line-height 0.22s ease, font-size 0.22s ease, background 0.15s ease; + /* Glued to the bar's bottom edge: --topbar-h is the bar's live height, so + the lip rides the bar's own collapse instead of running a second, + competing animation against it. */ + transform: translateY(var(--topbar-h, 46px)); + transition: width 0.16s ease, height 0.16s ease, + opacity 0.16s ease, border-radius 0.16s ease; } -#topbar-notch .accent { color: var(--bg); } /* the cutout */ -#topbar-notch:hover { background: color-mix(in srgb, var(--accent) 68%, transparent); } -/* bar hidden: the text folds away and the tab becomes a lip at the top edge */ -body.menu-hidden #topbar-notch { +#topbar-notch:hover, +#topbar-notch:focus-visible { + width: 60px; height: 10px; - line-height: 10px; - font-size: 0; - background: color-mix(in srgb, var(--accent) 55%, transparent); -} -body.menu-hidden #topbar-notch:hover { background: var(--accent); } -body.menu-hidden #topbar-notch::after { - content: ""; - display: block; - width: 20px; - height: 3px; - margin: 0 auto; - position: relative; - top: 3px; - border-radius: 999px; - background: var(--bg); + opacity: 1; + border-radius: 0 0 8px 8px; } +/* With the bar collapsed --topbar-h is 0, so the lip ends up at the very top + edge without a rule of its own. */ #brand { font-weight: 800; @@ -387,6 +373,22 @@ body.menu-hidden #topbar-notch::after { } .tile-notch:hover::after { background: var(--text); } +/* Fullscreen is for watching, not arranging: every title bar folds away, and + a tile's own hidden/shown preference is left untouched for when the screen + comes back. The notch still peeks one open, and it folds back once the + pointer has been off that tile for a while (PEEK_HIDE_MS in shell.js). */ +body.fullscreen .tile:not(.head-peek) .tile-head { + height: 0; + border-bottom-width: 0; + overflow: hidden; +} +body.fullscreen .tile:not(.head-peek) .tile-notch { top: 0; } +body.fullscreen .tile.head-peek .tile-head { + height: 32px; + border-bottom-width: 1px; +} +body.fullscreen .tile.head-peek .tile-notch { top: 32px; } + .tile-body { flex: 1; position: relative; @@ -433,8 +435,9 @@ body.menu-hidden #topbar-notch::after { 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 */ +/* bottom edge: drag to move the tile's bottom edge (height only). Drawn as + the same 2px line as the corner handles it sits between, on the same + 4px inset, so the three read as one set of marks along the bottom. */ .tile-resize-bottom { position: absolute; left: 24px; @@ -447,6 +450,7 @@ body.menu-hidden #topbar-notch::after { justify-content: center; cursor: ns-resize; touch-action: none; + color: var(--muted); opacity: 0; transition: opacity 0.15s ease; } @@ -454,17 +458,13 @@ body.menu-hidden #topbar-notch::after { content: ""; width: 44px; max-width: 60%; - height: 5px; - margin-bottom: 2px; - border-radius: 999px; - background: var(--muted); - transition: background 0.15s ease; + height: 0; + margin-bottom: 4px; + border-bottom: 2px solid currentColor; } .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); } +.tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); } /* ── seam divider between adjacent tiles ───────────────────────────── */ @@ -494,7 +494,8 @@ body.menu-hidden #topbar-notch::after { transition: opacity 0.15s ease, background 0.15s ease; } -.tile-divider:hover::after { opacity: 0.7; } +.tile-divider:hover::after, +.tile-divider.linked::after { opacity: 0.7; } .tile-divider.active::after { opacity: 1; background: var(--text); } /* Hide the pills while a tile itself is being moved or corner-resized — diff --git a/server.js b/server.js index 5655907..2bddb72 100644 --- a/server.js +++ b/server.js @@ -373,14 +373,17 @@ function mountModule(id) { // Re-require fresh config on every (re)mount; the module code itself stays cached. const mod = require(serverPath); const config = effectiveConfig(id); + // What a module needs to reach the rest of ProdDash — another module's + // routes over loopback, chiefly — without guessing. + const shell = { port: PORT }; if (typeof mod.init === 'function') { - entry.handle = mod.init({ config, log }) || null; + entry.handle = mod.init({ config, log, shell }) || null; } if (typeof mod.routes === 'function') { - entry.routes = parseRouteTable(mod.routes({ config, log })); + entry.routes = parseRouteTable(mod.routes({ config, log, shell })); } if (typeof mod.tiles === 'function') { - entry.tiles = () => mod.tiles({ config, log }); + entry.tiles = () => mod.tiles({ config, log, shell }); } log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : '')); } catch (err) { @@ -510,6 +513,8 @@ async function clientManifest(id) { instanceSchema: man.instanceSchema || {}, instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {}, hasServer: Boolean(man.server), + // capabilities other modules consume ("timers") — see the module guide + provides: Array.isArray(man.provides) ? man.provides.map(String) : [], config: clientConfig(id), tiles: await moduleTiles(id), };