Merge a6dc2dd (shell 1.4.0: shell.port, provides, module-guide contracts) into pco-plan-service-timers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+100
-11
@@ -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/<id>/
|
||||
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/<id>/…`). 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/<id>/…`. 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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+2
-3
@@ -15,8 +15,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<header id="topbar">
|
||||
<!-- invisible twin of the logo: holds its place in the bar; the real logo is the handle below -->
|
||||
<span id="brand" class="brand-spacer" aria-hidden="true">Prod<span class="accent">Dash</span></span>
|
||||
<span id="brand">Prod<span class="accent">Dash</span></span>
|
||||
|
||||
<span class="spacer"></span>
|
||||
|
||||
@@ -51,7 +50,7 @@
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar">Prod<span class="accent">Dash</span></button>
|
||||
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar"></button>
|
||||
|
||||
<main id="gridwrap">
|
||||
<div id="grid">
|
||||
|
||||
+174
-44
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
+54
-53
@@ -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 —
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user