Pin the contracts the next round of work meets at

Four pieces of work are about to run in parallel — the shell, the admin
page, the Timers module and the PCO Plan module — and three of them meet at
things that didn't exist yet. This writes those down first, so they are built
once, the same way.

- **Offering timers to the Timers module.** A module declares
  `"provides": ["timers"]` and serves `GET /timers` (a cheap snapshot with
  startedAt/elapsedMs/targetMs, an optional status line, optionally an SSE
  stream). The consumer extrapolates locally, so no fast polling. Modules meet
  over loopback through each other's routes, as PCO Plan already does to
  follow ProPresenter.
- **Admin group controls.** `configGroups[name].help` becomes an ⓘ beside the
  group title rather than a paragraph under it; `configGroups[name].toggle`
  names a switch that renders in the title row as the group's on/off.
- **Switches, labelled with nouns.** On means shown or enabled; the label
  shouldn't say so twice.
- **Themes** swap the shell variables; a tile's own configured colour is set
  closer and wins.

Two shell hooks make it real: `/api/modules` forwards `provides`, and module
servers get `shell.port` in init/routes/tiles instead of guessing it from
proddash.json.

Clock: descriptions gone, "Show seconds" is "Seconds" (1.0.1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-13 12:32:31 -04:00
co-authored by Claude Fable 5.1
parent 7b353cf7f8
commit a6dc2ddb85
3 changed files with 103 additions and 13 deletions
+92 -6
View File
@@ -92,6 +92,7 @@ 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. | | `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. | | `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. | | `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. | | `client` | yes | Client entry file, loaded as an ES module. |
| `server` | no | Server entry file (CommonJS), loaded with `require()`. | | `server` | no | Server entry file (CommonJS), loaded with `require()`. |
| `style` | no | A stylesheet the shell injects once per module. | | `style` | no | A stylesheet the shell injects once per module. |
@@ -115,8 +116,13 @@ Both schemas map field names to specs:
} }
``` ```
`boolean` renders as a checkbox; `switch` is the same true/false value shown `boolean` and `switch` are the same true/false value. In a tile's gear popover
as a toggle (use it for a feature on/off that other fields depend on). 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 `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`). 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. 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 - `columns`: 2–4 lays the group's fields out in a grid — the way to keep a
long checklist of booleans compact. 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 `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 placeholders, where a value comes from. Keep labels short and put the
@@ -365,7 +379,11 @@ own in `stop()` anyway.
palette: `--bg` (page), `--panel` (tile), `--border`, `--text`, palette: `--bg` (page), `--panel` (tile), `--border`, `--text`,
`--muted`, `--accent`, `--danger`, `--warn`. Derive tints with `--muted`, `--accent`, `--danger`, `--warn`. Derive tints with
`color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes `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 3. **No external network calls from the client.** Browsers talk only to
ProdDash. Anything upstream (your device, service, API) goes through your ProdDash. Anything upstream (your device, service, API) goes through your
@@ -385,7 +403,7 @@ ethos; Node 18+ so global `fetch` is available). Exports either or both of:
```js ```js
module.exports = { module.exports = {
init({ config, log }) { init({ config, log, shell }) {
// Called on startup / re-init, BEFORE routes(). Start your upstream // Called on startup / re-init, BEFORE routes(). Start your upstream
// connection here. Return a handle: // connection here. Return a handle:
return { return {
@@ -394,7 +412,7 @@ module.exports = {
}; };
}, },
routes({ config, log }) { routes({ config, log, shell }) {
// Route table, mounted under /api/modules/<id>/ // Route table, mounted under /api/modules/<id>/
return { return {
'GET /state': (req, res) => { … }, 'GET /state': (req, res) => { … },
@@ -410,6 +428,9 @@ module.exports = {
- `config` is your admin config: `configSchema` defaults overlaid with what - `config` is your admin config: `configSchema` defaults overlaid with what
the admin page saved — **including password fields**. the admin page saved — **including password fields**.
- `log(...)` prefixes output with your module id. - `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 - Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is
the matched remainder (leading `/` included) and `req.search` is the query the matched remainder (leading `/` included) and `req.search` is the query
string (with `?`, or `''`) — so a proxy rebuilds the upstream path as string (with `?`, or `''`) — so a proxy rebuilds the upstream path as
@@ -496,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 `propresenter-now-next` module is the reference implementation of this
pattern. 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 ## Complete minimal example
A working clock — copy-paste this as your starter. (A slightly fancier A working clock — copy-paste this as your starter. (A slightly fancier
+3 -4
View File
@@ -1,9 +1,8 @@
{ {
"id": "clock", "id": "clock",
"name": "Clock", "name": "Clock",
"version": "1.0.0", "version": "1.0.1",
"proddash": ">=1.1.0", "proddash": ">=1.1.0",
"description": "Big booth clock — the smallest possible ProdDash module",
"client": "client.js", "client": "client.js",
"style": "style.css", "style": "style.css",
"minSize": { "w": 2, "h": 1 }, "minSize": { "w": 2, "h": 1 },
@@ -18,7 +17,7 @@
{ "value": "12h", "label": "12-hour" } { "value": "12h", "label": "12-hour" }
] ]
}, },
"showSeconds": { "type": "boolean", "label": "Show seconds", "default": true }, "showSeconds": { "type": "boolean", "label": "Seconds", "default": true },
"showDate": { "type": "boolean", "label": "Show date", "default": false } "showDate": { "type": "boolean", "label": "Date", "default": false }
} }
} }
+8 -3
View File
@@ -373,14 +373,17 @@ function mountModule(id) {
// Re-require fresh config on every (re)mount; the module code itself stays cached. // Re-require fresh config on every (re)mount; the module code itself stays cached.
const mod = require(serverPath); const mod = require(serverPath);
const config = effectiveConfig(id); 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') { 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') { 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') { 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)` : '')); log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : ''));
} catch (err) { } catch (err) {
@@ -510,6 +513,8 @@ async function clientManifest(id) {
instanceSchema: man.instanceSchema || {}, instanceSchema: man.instanceSchema || {},
instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {}, instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {},
hasServer: Boolean(man.server), 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), config: clientConfig(id),
tiles: await moduleTiles(id), tiles: await moduleTiles(id),
}; };