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:
co-authored by
Claude Fable 5.1
parent
7b353cf7f8
commit
a6dc2ddb85
+92
-6
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user