The machine running ProdDash is a packaged app or the Windows bundle plus modules from the admin page's catalog. It has no compiler, and the catalog delivers exactly the folder that is in the repo — so a module's native program (audio capture, a decoder) ships already built, inside the module folder, one file per platform: bin/<name>-macos-arm64 and bin/<name>-win-x64.exe are required, Intel macOS optional, source and the script that rebuilds every binary under native/. The guide's rule 3 now says so, "The shape of a module" shows the layout, and a new "Native helpers" section gives the contract: pick the binary by platform at runtime, report the feature off when there is none — never build, never search the machine, never ask the operator to install anything — one command-line contract in any language per platform, bump the version when a binary changes, keep them lean, sign the macOS one. CLAUDE.md carries the one-line version. Rules only: no module changes here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
903 lines
45 KiB
Markdown
903 lines
45 KiB
Markdown
# ProdDash Module Guide
|
||
|
||
How to build, install and test a ProdDash module. Everything a module author
|
||
needs is on this page — you never have to read the shell's source.
|
||
|
||
A **module** is one kind of dashboard tile: the live transcript, the
|
||
Now/Next display, a clock. Users add any number of tiles of your module to
|
||
their own dashboards, move and resize them, and give each tile its own
|
||
per-tile settings. Your module renders inside the tile and talks to the
|
||
outside world only through the small API the shell hands it.
|
||
|
||
## Rules every module follows
|
||
|
||
These are not style preferences — the admin page, the installer and the
|
||
people running the booth depend on them.
|
||
|
||
1. **All settings live in the ProdDash admin page.** Server-wide settings go
|
||
in `configSchema`, per-tile settings in `instanceSchema`. Never a separate
|
||
setup page, wizard, config file the operator edits by hand, or environment
|
||
variable.
|
||
2. **Dependencies ship inside the module folder.** Zero dependencies is the
|
||
norm (the shell has none). Anything you genuinely need is vendored into
|
||
the module — or fetched by your own `init()` on first start, with the
|
||
admin health line saying what is happening. No `npm install`, no
|
||
installer, no "run this first".
|
||
3. **Everything the module does runs inside the module.** No helper scripts,
|
||
companion programs, daemons or services the operator has to start next to
|
||
ProdDash. If your feature needs a listener, a decoder or a worker, your
|
||
server entry owns it: start it in `init()`, stop it in `stop()`, report
|
||
it in `health()`. A child process the module itself spawns and supervises
|
||
is fine; a program someone launches by hand is not. **A native helper —
|
||
anything that is not JavaScript — ships prebuilt inside the module folder,
|
||
for Apple silicon macOS and 64-bit Windows; nothing is ever compiled on
|
||
the machine running ProdDash** (see "Native helpers" below).
|
||
4. **Declare the ProdDash version you need** (`"proddash": ">=1.1.0"` in the
|
||
manifest) and **bump your own `version`** whenever you change the module.
|
||
The admin page refuses to install a module whose requirement the running
|
||
shell doesn't meet, and it offers updates by comparing versions.
|
||
|
||
### Keeping the interface clean
|
||
|
||
A dashboard is read across a booth, at a glance, by someone doing three
|
||
other things. These rules are what keep every tile looking like one product
|
||
instead of six. They are as binding as the four above.
|
||
|
||
5. **No explanatory text in a tile's gear.** No `help` on an
|
||
`instanceSchema` field or an `instanceGroups` entry. Labels are **three
|
||
words at most, one or two preferred** — "Slide count", "Item tags",
|
||
"Running clock". A control that cannot be understood in three words is
|
||
not a display choice; it belongs in `configSchema`, where `help` is
|
||
welcome and expected. *One exception:* a `template` field may keep a
|
||
single `help` line naming its placeholders, which nobody can guess.
|
||
6. **Server-side settings are admin-only.** Anything the module's server
|
||
acts on — a host, a credential, what to fetch, how often to poll — is
|
||
`configSchema`. `instanceSchema` is display choices and nothing else:
|
||
what this tile shows, and how big.
|
||
7. **A tile setting changes that tile and nothing else.** Never another
|
||
tile, never the module as a whole, never the server. If two tiles must
|
||
agree on something, it is either admin config or page state the shell
|
||
owns — not a setting each tile keeps its own copy of.
|
||
8. **Offer toggles for the elements you draw.** Every tile should let the
|
||
operator switch off the parts it does not need. **One to four toggles:**
|
||
give them no `group` at all, so they sit unlabelled at the top of the
|
||
gear. **Five or more:** one group named **Show**, `collapsed: true`,
|
||
`columns: 2`. Nothing else goes in that group — colours, formats and
|
||
text belong in groups of their own.
|
||
9. **Title-bar controls are icons.** No `label` on `addButton` or
|
||
`addMenu`; the tooltip carries the words, and `docs` above covers drawing
|
||
the icon. A small state indicator may ride alongside an icon — ProdCom's
|
||
channel button shows a count while a filter is on — but a control is
|
||
never named in the bar.
|
||
10. **If a control needs words to explain it, it is not a title-bar
|
||
control.** Put it in the gear. The bar is for actions whose icon and
|
||
tooltip say everything: refresh, clear, jump to latest, bigger text.
|
||
11. **One control per action.** Whatever has a dedicated title-bar button is
|
||
not offered again in the gear, and the reverse. Text size is A− / A+ up
|
||
there, never a "Text size" field; a timestamps button in the bar means
|
||
no timestamps switch below it.
|
||
|
||
## The shape of a module
|
||
|
||
A module is one folder dropped into `modules/`:
|
||
|
||
```
|
||
modules/<id>/
|
||
├── module.json manifest (required)
|
||
├── client.js ES module, client entry (required)
|
||
├── server.js server entry (optional — only if you need server code)
|
||
├── bin/ native helpers, prebuilt per platform (only if you need one)
|
||
│ ├── <name>-macos-arm64
|
||
│ └── <name>-win-x64.exe
|
||
├── native/ their source and the script that builds every bin/ file
|
||
└── ... assets (css, images…) served statically at /modules/<id>/
|
||
```
|
||
|
||
**Installation is exactly this:** in the admin page, open **Available
|
||
modules** and press **Install** — the module is downloaded from the ProdDash
|
||
repo into the server's data directory (`<data dir>/modules/<id>/`) and starts
|
||
immediately. Developing locally? Drop the folder into `modules/` (bundled
|
||
with the checkout) or into the data directory's `modules/` and restart. No
|
||
shell code changes, ever. Modules bundled with a checkout and modules
|
||
installed from the admin page live side by side; when both have the same id
|
||
the newer version runs. **Uninstall** in the admin page removes an installed
|
||
module's files (a bundled one is hidden instead, since its folder belongs to
|
||
the app); its settings are kept for a later reinstall.
|
||
|
||
### Native helpers
|
||
|
||
Some features need a program JavaScript cannot be: reading an audio input,
|
||
decoding timecode, measuring sound. Rule 3 says the module owns that program;
|
||
this section says how it ships: **already built, for every platform it runs
|
||
on, inside the module folder.** The machine running ProdDash is a packaged
|
||
app or the Windows bundle plus modules from the admin page's catalog. It has
|
||
no compiler and never will — the catalog delivers exactly the folder that is
|
||
in the repo, so the binaries live in git next to the code.
|
||
|
||
- **Platforms.** Apple silicon macOS and 64-bit Windows are required. Intel
|
||
macOS is welcome but optional. One file per platform in `bin/`, named for
|
||
what it is:
|
||
|
||
| Platform | File |
|
||
| --- | --- |
|
||
| macOS, Apple silicon | `bin/<name>-macos-arm64` |
|
||
| Windows, 64-bit | `bin/<name>-win-x64.exe` |
|
||
| macOS, Intel (optional) | `bin/<name>-macos-x86_64` |
|
||
|
||
- **Pick it at runtime, and cope without it.** Choose by `process.platform`
|
||
and `process.arch`; when there is no file for this machine, the feature
|
||
says so — in `health()` and in the tile's `setStatus` — and everything else
|
||
in the module keeps working. Never try to build it, never look for a copy
|
||
elsewhere on the machine, never tell the operator to install anything.
|
||
|
||
```js
|
||
const HELPERS = {
|
||
darwin: { arm64: 'ltc-capture-macos-arm64', x64: 'ltc-capture-macos-x86_64' },
|
||
win32: { x64: 'ltc-capture-win-x64.exe' },
|
||
};
|
||
const name = HELPERS[process.platform]?.[process.arch];
|
||
const helper = name ? path.join(__dirname, 'bin', name) : '';
|
||
if (!helper || !fs.existsSync(helper)) {
|
||
// Timecode is off on this platform. Say it once, keep the clock running.
|
||
}
|
||
```
|
||
|
||
- **One contract, any language.** Every platform's binary speaks the same
|
||
command line and output format, so `server.js` has one code path. What is
|
||
behind it is your choice per platform — Swift against CoreAudio on macOS,
|
||
C#, C or Rust on Windows — the module does not care.
|
||
- **Source and build beside it.** Keep the source under `native/` with one
|
||
script that produces every `bin/` file from a checkout, so any binary can be
|
||
rebuilt from what is committed. A helper that cannot be rebuilt does not
|
||
ship.
|
||
- **Bump the module version whenever a binary changes**, like any other
|
||
file — that is how installs learn about it.
|
||
- **Keep them lean.** Optimised builds, no debug symbols, a few megabytes at
|
||
most: the catalog fetches the whole repo as one archive, capped at 80 MB.
|
||
- **Signing.** Apple silicon refuses unsigned code, so sign the macOS binary
|
||
— Developer ID when you have it, ad-hoc at least. Windows needs nothing:
|
||
files the app writes carry no download mark, so SmartScreen never sees
|
||
them. Permissions come from the launcher (see "Permissions macOS has to
|
||
grant"), not from the binary.
|
||
|
||
A helper that ends up existing for one platform only is an exception made
|
||
on purpose, not a default: say so in the module's README and in the health
|
||
line, so a Windows booth knows why the feature is off.
|
||
|
||
## module.json
|
||
|
||
```json
|
||
{
|
||
"id": "my-module",
|
||
"name": "My Module",
|
||
"version": "1.0.0",
|
||
"proddash": ">=1.1.0",
|
||
"description": "One line shown in the Add-tile picker and the admin page",
|
||
"permissions": [
|
||
{ "kind": "microphone", "reason": "Why this module needs it, in one line" }
|
||
],
|
||
"client": "client.js",
|
||
"server": "server.js",
|
||
"style": "style.css",
|
||
"minSize": { "w": 2, "h": 2 },
|
||
"defaultSize": { "w": 4, "h": 3 },
|
||
"configSchema": {
|
||
"upstreamUrl": { "type": "string", "label": "Upstream URL", "default": "http://10.0.0.5:1234" },
|
||
"apiKey": { "type": "password", "label": "API key", "default": "" }
|
||
},
|
||
"instanceSchema": {
|
||
"channel": { "type": "string", "label": "Channel filter", "default": "" }
|
||
}
|
||
}
|
||
```
|
||
|
||
| Field | Required | Meaning |
|
||
| --- | --- | --- |
|
||
| `id` | yes | Must equal the folder name. Lowercase, dashes. |
|
||
| `name` | yes | Human name shown on the tile header and in menus. |
|
||
| `version` | yes | Your module's version (`x.y.z`). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one. |
|
||
| `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, declared for the default grid (12 columns, 72 px rows). Defaults: min 1×1, default 4×3. A dashboard may cut its board finer or coarser (8–24 columns, from the Layouts menu; row height follows). `defaultSize` is scaled to that density when a tile is added, so a new tile lands at about the size you meant; `minSize` is enforced in cells as declared, so on a finer grid a tile can be made smaller than it could at 12. |
|
||
| `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). 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:
|
||
|
||
```
|
||
{ "type": "string" | "text" | "number" | "boolean" | "switch" | "select" | "multiselect" | "template" | "color" | "password" | "endpoint",
|
||
"label": "Shown next to the field",
|
||
"default": <value>,
|
||
"group": "Section heading", // optional; see below
|
||
"showWhen": "<other boolean/switch key>", // optional; see below
|
||
"help": "One line shown under the control", // optional, admin config only
|
||
"options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ], // "select" only
|
||
"optionsRoute": "/devices", // "select" only, admin config only
|
||
"optionsDependsOn": "<other key>" // with optionsRoute; see below
|
||
}
|
||
```
|
||
|
||
`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`).
|
||
`multiselect` is several picks from one list — the same `options` /
|
||
`optionsRoute` as `select`, rendered as one switch per option and stored as
|
||
an array of values. A saved value the list no longer offers stays checked and
|
||
marked, so nothing is dropped behind anyone's back. In a tile's gear popover
|
||
it also takes `columns` (2–4) and `allByDefault: true`: a tile that has never
|
||
chosen shows every option on and saves nothing until someone flips one, so
|
||
options that turn up later are on as well (give it no `default` then).
|
||
`select` and `multiselect` may use `optionsRoute` in `instanceSchema` too —
|
||
the popover GETs `/api/modules/<id><optionsRoute>` like the admin page does.
|
||
|
||
`template` (tile settings) is a text with placeholders, built by dragging or
|
||
clicking the variables the module offers into it: `"variables": [ { "value":
|
||
"{series}", "label": "Series" }, … ]`. Keep `help` to the joining rules; the
|
||
chips carry the names.
|
||
|
||
`text` is a multi-line string — a `<textarea>` in the admin page, stored with
|
||
its newlines; one item per line is the convention (the `slack` module's quick
|
||
replies). Admin config only.
|
||
|
||
`group` gathers consecutive fields under a labeled subsection in the admin
|
||
page, so a module with several distinct concerns (e.g. a ProPresenter
|
||
connection and an LTC audio input) reads as clearly separated blocks. Fields
|
||
with no `group` render loose, before/after the grouped ones in schema order.
|
||
|
||
A manifest-level `configGroups` map can dress a group up further, keyed by
|
||
the group name:
|
||
|
||
```json
|
||
"configGroups": {
|
||
"Planning Center credentials": { "collapsed": "whenSet", "help": "Where to get a token…" },
|
||
"Show / hide": { "collapsed": true, "columns": 3 }
|
||
}
|
||
```
|
||
|
||
- `collapsed`: `true` (starts folded), `false` (starts open but can fold),
|
||
or `"whenSet"` — folded once the group already holds values (a saved
|
||
password, or anything other than the schema default), open while it is
|
||
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`: 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
|
||
explanation there. **This is for `configSchema` only**: a tile's gear
|
||
carries no explanations at all (rule 5), so a setting that needs one is
|
||
telling you it belongs in admin.
|
||
|
||
`group` works on `instanceSchema` fields too, and a manifest
|
||
`instanceGroups` map plays the `configGroups` role for the tile's gear
|
||
popover (`collapsed: true | false`, `columns`, `toggle` — but not `help`,
|
||
per rule 5). That is how a module with a long show/hide checklist keeps the
|
||
popover usable: five or more hide-toggles go in one **Show** group, folded
|
||
by default, and fewer than five go in no group at all — see `pco-plan` for
|
||
the long form and `slack` for the short one. Display choices belong there,
|
||
per tile; admin config is for connections. The popover itself is a small window beside the tile — sized to
|
||
its content, moved by the slim bar on top, closed by clicking anywhere else
|
||
or pressing Escape — so a tile never has to be big enough to hold its own
|
||
settings, and a change can be watched as it lands. Under everything you
|
||
offer, the shell adds **Tile tint** — a colour the operator can wash that
|
||
tile's chrome in, saved with the layout. It is not yours to declare and it
|
||
never remounts you; don't offer one of your own.
|
||
|
||
A picker entry (see **Presenting multiple tiles**) may carry its own
|
||
`instanceSchema` and `instanceGroups`: tiles added from that entry get those
|
||
instead of the module's. A tile that shows one ProPresenter timer has no use
|
||
for a clock's format switches — give it a schema of what applies.
|
||
|
||
`showWhen: "<key>"` hides a field until the boolean/`switch` field named
|
||
`<key>` is on — e.g. the audio device and channel appear only once the LTC
|
||
listener switch is enabled. The hidden field still keeps its stored value.
|
||
It works the same in a tile's gear (`instanceSchema`): "Short day" and
|
||
"Short month" appear once the clock's "Date" is on.
|
||
|
||
A `select` whose choices are only knowable at runtime (audio devices, serial
|
||
ports, discovered sources) sets `optionsRoute` instead of — or as a fallback
|
||
refresh of — static `options`: the admin page GETs
|
||
`/api/modules/<id><optionsRoute>` and expects `{ "options": [ { "value",
|
||
"label" }, … ] }` from one of the module's own server routes. The saved value
|
||
stays selectable even when the route is down or the option has vanished. (See
|
||
`propresenter-timers`' "Audio device" for a worked example.)
|
||
|
||
An option may carry a `group`: options sharing one render under a single
|
||
`<optgroup>`, so a tree (Planning Center folders, device classes) is
|
||
browsable inside the select. `optionsDependsOn: "<key>"` makes the admin
|
||
page fetch the options with that other field's current value appended as a
|
||
query parameter (`?<key>=<value>`), now and again whenever it changes — e.g.
|
||
a plan list that follows the chosen service type. (Both in `pco-plan`.)
|
||
|
||
`password` fields are admin-config only in practice: their values are stored
|
||
in the server's `modules.json` (in the per-machine data directory, see the
|
||
README), handed to your *server* entry, and **never sent to browsers** — not in `/api/modules`, and never echoed back into the admin
|
||
form. Anything upstream that needs the secret must go through your server
|
||
routes.
|
||
|
||
### Connection settings: use `endpoint`
|
||
|
||
Anything that points at a device or service on the network — ProPresenter,
|
||
ProdCom, a camera, a mixer — must be **one `endpoint` field**, so every
|
||
module's connection is entered the same way. The admin page renders an
|
||
endpoint as a single "Host / IP : Port" control; the value is stored and
|
||
handed to your server as an object:
|
||
|
||
```json
|
||
"propresenter": {
|
||
"type": "endpoint",
|
||
"label": "ProPresenter",
|
||
"default": { "host": "", "port": 1025 }
|
||
}
|
||
```
|
||
|
||
```js
|
||
// in your server entry
|
||
const { host, port } = config.propresenter;
|
||
const base = `http://${host}:${port}`;
|
||
```
|
||
|
||
Do **not** split host and port into separate `string`/`number` fields, and
|
||
do **not** ask for a full `http://…` URL unless the setting genuinely is a
|
||
web address where protocol or path matter (a page to embed, a webhook).
|
||
Endpoints belong in admin config, not per-tile settings — connections are
|
||
server-wide by design.
|
||
|
||
Admin config lives in `modules.json` inside the server's data directory
|
||
(written by the admin page — your module never touches that file, and it
|
||
survives updates of the app folder). Per-tile settings live inside each
|
||
browser's layout.
|
||
|
||
### Permissions macOS has to grant
|
||
|
||
Some things a module does need the operating system's permission, not just
|
||
configuration: reading an audio input, reaching gear on the local network.
|
||
macOS grants those to the **application that owns the process**, which for
|
||
ProdDash is the macOS launcher (`launcher/macos`) — the server is its child,
|
||
so the prompts are asked, and remembered, in ProdDash's name. A server
|
||
started over SSH or from a bare launchd job has no such owner, and the
|
||
permission is silently absent rather than refused: an LTC input reads as
|
||
endless zeroes, a local host simply never answers.
|
||
|
||
Declare what you need, and why, so the launcher's Permissions tab can list
|
||
it and ask for it before a service rather than during one:
|
||
|
||
```json
|
||
"permissions": [
|
||
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" },
|
||
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
|
||
]
|
||
```
|
||
|
||
| `kind` | When to declare it |
|
||
| --- | --- |
|
||
| `microphone` | The module reads an audio input (`ltc-capture` and anything like it). |
|
||
| `local-network` | The module talks to gear on this building's network. Not needed for services out on the internet — Planning Center, say. |
|
||
|
||
`reason` is one line, shown to the operator as-is when they hover the
|
||
permission; modules giving the same reason are listed together. A bare `"permissions": ["microphone"]` works when
|
||
the standing explanation is enough. A `kind` the launcher doesn't manage is
|
||
still listed, so nothing goes unmentioned — the shell itself ignores the key
|
||
entirely, and other platforms ignore it too.
|
||
|
||
## Client entry (`client.js`)
|
||
|
||
An ES module whose **default export is a factory**. The shell calls it once
|
||
per tile:
|
||
|
||
```js
|
||
export default function create({ root, moduleApi }) {
|
||
return {
|
||
start() {}, // begin rendering / connecting
|
||
stop() {}, // tear down EVERYTHING (timers, streams, observers)
|
||
onResize(w, h) {}, // optional; tile was resized (w, h in grid cells)
|
||
onConfigChange(cfg) {} // optional; admin config was saved
|
||
};
|
||
}
|
||
```
|
||
|
||
- `root` — the tile's body element. **The module owns everything inside it
|
||
and must never touch DOM outside it.**
|
||
- All four methods are optional, but a module without `stop()` will leak
|
||
timers when its tile is removed. `stop()` must leave nothing running.
|
||
|
||
### moduleApi
|
||
|
||
| Member | What it is |
|
||
| --- | --- |
|
||
| `id` | Your module id. |
|
||
| `instanceId` | Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile. |
|
||
| `variant` | For multi-tile modules: the id of the tile-list entry this tile was added as, `''` otherwise (see **Presenting multiple tiles**). |
|
||
| `config` | Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client. |
|
||
| `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. Keys beginning `__` are the shell's own (`__tint`, the tile's colour) — never use that prefix, and ignore the ones you find. |
|
||
| `saveInstanceSettings(patch)` | Merge `patch` into this tile's settings and persist them with the layout. No restart happens — you made the change, you already know. Use it for state the user sets *inside* your tile (a toggled filter, a chosen tab). |
|
||
| `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules/<id>/state`. |
|
||
| `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. |
|
||
| `setStatus(state, msg)` | Drives the tile's status dot: `'ok'`, `'connecting'` or `'error'`, plus a hover message. Call it honestly — during a live service the dot is how an operator spots a dead feed. |
|
||
| `header.addButton(…)` | Put a small button in the tile's own title bar (see below). |
|
||
| `header.addMenu(…)` | Put a dropdown menu button there (see below). |
|
||
|
||
### Title-bar controls
|
||
|
||
Modules don't build their own toolbars — actions live in the tile's title
|
||
bar, next to the shell's gear/close buttons:
|
||
|
||
```js
|
||
const btn = moduleApi.header.addButton({
|
||
icon: '<svg …>…</svg>', // inline SVG string (13 px), and/or
|
||
label: 'Timers ▾', // short text
|
||
title: 'Larger text', // tooltip — one or two words; the shell shows
|
||
// it the moment the pointer arrives
|
||
onClick() { … },
|
||
});
|
||
btn.classList.add('active'); // toggle styling for on/off buttons
|
||
|
||
const menu = moduleApi.header.addMenu({
|
||
label: 'Channels ▾',
|
||
title: 'Channels',
|
||
build(menuEl) {
|
||
// called on every open with an emptied menu element — fill it with
|
||
// buttons (give them your scoped classes; the container is styled and
|
||
// fixed-positioned by the shell, so the tile's overflow can't clip it)
|
||
},
|
||
});
|
||
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
||
```
|
||
|
||
Everything you add is removed automatically when your instance stops, so a
|
||
remount never duplicates controls. **Icons only, no labels** (rule 9): the
|
||
title bar is 32 px tall and shared with the tile's name, and the tooltip
|
||
says what the icon means. Draw icons as inline SVG rather than characters —
|
||
a glyph such as ✕ or + takes its size from whatever font each browser
|
||
picks; text-size buttons are a drawn − and +. A small state indicator may
|
||
ride alongside an icon (ProdCom's channel button carries a count while a
|
||
filter is on); a name may not.
|
||
|
||
**One control per action** (rule 11). Whatever has a dedicated header button
|
||
is not offered again in the gear popover: text size is − / + up here
|
||
(persisted through `saveInstanceSettings({ textSize })`, read back with a
|
||
sane fallback), never a "Text size" field in `instanceSchema`; a timestamps
|
||
or newest-first toggle in the header means no such switch in the gear. The
|
||
gear is for display choices that have no button of their own.
|
||
|
||
One more thing to know: the whole title bar folds away when the dashboard
|
||
auto-hides it, and always in fullscreen (a clean-view mode for wall
|
||
displays) — it comes back only while the pointer is at the tile's top edge.
|
||
Your controls fold away with it, so nothing essential — status, reconnects,
|
||
live data — may exist *only* as a header control.
|
||
|
||
`sse(path, handlers)` handlers:
|
||
|
||
```js
|
||
const stream = moduleApi.sse('/stream', {
|
||
open(e) {}, // (re)connected
|
||
error(e) {}, // connection lost — say so via setStatus
|
||
message(e) {}, // unnamed "data:" events
|
||
events: { state(e) {} }, // named events by name
|
||
});
|
||
// later: stream.close()
|
||
```
|
||
|
||
Your route serves a plain SSE stream; how it reaches the page is the
|
||
shell's business. A dashboard holds **one** connection for every stream on
|
||
it (`GET /api/stream`, which the server feeds from your route over
|
||
loopback) — browsers allow about six connections to a host and a tile per
|
||
stream ran out of them — so `open` also fires whenever that shared
|
||
connection is remade (a tile was added or removed) and whenever your route
|
||
comes back after a drop. Browsers' `EventSource` retries transient drops
|
||
itself but gives up for good when a retry gets a completed non-SSE
|
||
response — which is what a proxy's 502 looks like while the upstream is
|
||
down; the shell recreates a closed connection on a 3-second timer until it
|
||
works again, so **reconnect logic is free**: handle `open` (refresh/backfill
|
||
your state; you may have missed events) and `error` (report degraded
|
||
status), and you're resilient. Streams you forget to close are force-closed
|
||
when the tile unmounts — but close your own in `stop()` anyway.
|
||
|
||
### Lifecycle you must expect
|
||
|
||
- **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.** 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
|
||
|
||
1. **Never touch DOM outside `root`. Never define global CSS.** Scope every
|
||
rule in your stylesheet with the attribute the shell puts on your tiles:
|
||
|
||
```css
|
||
[data-module="my-module"] .my-thing { … }
|
||
```
|
||
|
||
(Shadow DOM works too, if you prefer full isolation.)
|
||
|
||
2. **Style with the shell's CSS variables — no hardcoded colors.** The
|
||
palette: `--bg` (page), `--panel` (tile), `--border`, `--text`,
|
||
`--muted`, `--accent`, `--accent-text` (text that sits on the accent —
|
||
dark on most themes, white where the accent is deep), `--danger`,
|
||
`--warn`. Derive tints with
|
||
`color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes
|
||
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
|
||
module's server routes — same reasoning as the reference apps: no CORS,
|
||
no per-browser credentials, one place that owns the connection.
|
||
|
||
4. **Handle upstream loss.** Reconnect with backoff (or lean on `sse()`),
|
||
and report honestly via `setStatus`. Tiles run unattended during live
|
||
services; a module that silently freezes is worse than one that says
|
||
it's down.
|
||
|
||
## Server entry (`server.js`)
|
||
|
||
Optional — only needed when your module talks to something upstream or keeps
|
||
server-side state. CommonJS, Node built-ins only (keep the zero-dependency
|
||
ethos; Node 18+ so global `fetch` is available). Exports either or both of:
|
||
|
||
```js
|
||
module.exports = {
|
||
init({ config, log, shell }) {
|
||
// Called on startup / re-init, BEFORE routes(). Start your upstream
|
||
// connection here. Return a handle:
|
||
return {
|
||
stop() {}, // tear everything down
|
||
health() { return { status: 'ok', message: '' }; } // optional
|
||
};
|
||
},
|
||
|
||
routes({ config, log, shell }) {
|
||
// Route table, mounted under /api/modules/<id>/
|
||
return {
|
||
'GET /state': (req, res) => { … },
|
||
'GET /stream': (req, res) => { … }, // SSE — see notes
|
||
'POST /command': (req, res) => { … },
|
||
'GET /proxy/*': (req, res) => { … }, // trailing /* = prefix route
|
||
'* /anything/*': (req, res) => { … }, // * method = any method
|
||
};
|
||
},
|
||
};
|
||
```
|
||
|
||
- `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
|
||
`req.wildcard + req.search`.
|
||
- Exact routes match the path exactly; prefix routes (`/x/*`) match `/x` and
|
||
everything under it. First declared match wins.
|
||
- **Re-init instead of restart:** when the admin page saves your config, the
|
||
shell calls your handle's `stop()`, then `init()` and `routes()` again
|
||
with the new config. Keep module-level state inside `init()`'s closure (or
|
||
reset it there) so a re-init starts clean. Disabling a module unmounts its
|
||
routes entirely (clients get 404).
|
||
- `health()` feeds the admin page's status line — report your upstream's
|
||
reachability (`status: 'ok' | 'connecting' | 'error'`), not just "running".
|
||
- A throw from `init()`/`routes()` marks the module as failed: the error
|
||
shows in `/admin`, and API calls answer 502 until a config fix re-inits
|
||
it. Validate config early so bad input fails loudly there.
|
||
|
||
### Presenting multiple tiles
|
||
|
||
By default a module is one entry in the + Add-tile picker. A module can
|
||
instead present a **list of tiles** — one picker entry each, grouped under
|
||
the module's name. This is how a module whose content is naturally plural
|
||
works: one tile per ProPresenter timer, one tile per admin-configured page.
|
||
|
||
Export `tiles` from your server entry (sync or async):
|
||
|
||
```js
|
||
module.exports = {
|
||
init({ config, log }) { … },
|
||
routes({ config, log }) { … },
|
||
|
||
tiles({ config, log }) {
|
||
// e.g. derived from admin config, or discovered from your upstream
|
||
return config.pages.map((page) => ({
|
||
id: page.name, // stable id, stored with the tile
|
||
name: page.name, // picker label AND the tile's title
|
||
description: page.url, // optional; the picker's tooltip
|
||
settings: { page: page.name }, // preset instance settings
|
||
defaultSize: { w: 4, h: 3 }, // optional per-entry size overrides
|
||
minSize: { w: 2, h: 2 },
|
||
}));
|
||
},
|
||
};
|
||
```
|
||
|
||
- `tiles()` runs on **every picker load**, so the list may be live data —
|
||
keep it fast (answer from state you already hold; don't fetch upstream on
|
||
demand). A throw or a hang (>2 s) falls back to the classic single entry,
|
||
so a dead upstream can't break the picker.
|
||
- `instanceSchema` / `instanceGroups` on an entry replace the module's for
|
||
tiles added from it — settings that fit that tile, nothing else.
|
||
- `aliases: ['old-id', …]` on an entry names earlier variant ids it stands
|
||
for: a tile added under one of them (a layout saved by an older version of
|
||
your module) gets this entry's settings too. A tile whose variant matches
|
||
no entry and no alias falls back to the module's full `instanceSchema`.
|
||
- Entry `description`s are tooltips in the + picker now, not visible lines —
|
||
one short sentence.
|
||
- `single: true` on an entry allows at most one such tile per dashboard: the
|
||
picker greys the entry out once it is placed. Use it for a control surface
|
||
that would only confuse in duplicate — a send box, a master switch.
|
||
- When the user adds an entry, its `settings` overlay your `instanceSchema`
|
||
defaults in that tile's instance settings, the entry's `name` becomes the
|
||
tile's title, and `moduleApi.variant` carries its `id`.
|
||
- **A tile is shown only while its entry is offered.** The shell mounts a
|
||
tile added from an entry only when that entry (or one of its `aliases`) is
|
||
in your list right now; otherwise the tile keeps its place in the layout,
|
||
draws nothing, and appears the moment your list offers it again — a
|
||
Service timer waits for PCO Plan, a transcript for its channel. So your
|
||
client is never asked to render an entry you did not offer at mount time.
|
||
One case is still yours: an entry that vanishes *after* the tile mounted
|
||
(a timer deleted in ProPresenter mid-service). Render nothing for it — no
|
||
placeholder, no error — and the shell folds the tile away at its next
|
||
registry refresh. Never fall back to your default view in its place: five
|
||
different timers turning into five "All timers" is worse than five gaps.
|
||
- A client-only module can declare a fixed list as a `tiles` array in
|
||
`module.json` instead (same entry shape).
|
||
- An empty list (or no `tiles` at all) keeps today's behavior: one picker
|
||
entry, no preset.
|
||
|
||
### Server-Sent Events from a module
|
||
|
||
For live data, expose a `/stream` route and let clients use
|
||
`moduleApi.sse('/stream', …)`:
|
||
|
||
```js
|
||
'GET /stream': (req, res) => {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||
'Cache-Control': 'no-cache, no-transform',
|
||
Connection: 'keep-alive',
|
||
});
|
||
res.write('retry: 3000\n\n');
|
||
res.write(`event: state\ndata: ${JSON.stringify(latest)}\n\n`); // instant paint
|
||
streams.add(res);
|
||
req.on('close', () => streams.delete(res));
|
||
}
|
||
```
|
||
|
||
Broadcast only when the payload actually changed, send a `: ping\n\n`
|
||
comment every ~15 s so idle connections survive sleepy Wi-Fi, and wrap every
|
||
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. |
|
||
| `asOf` | Optional, per timer: ms epoch this timer's durations were measured, when that differs from the snapshot's `updatedAt` (e.g. a value phase-locked to an upstream's second flips). Consumers extrapolate from it instead. |
|
||
| `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
|
||
version ships in `modules/clock/`.)
|
||
|
||
`modules/hello-clock/module.json`
|
||
|
||
```json
|
||
{
|
||
"id": "hello-clock",
|
||
"name": "Hello Clock",
|
||
"version": "1.0.0",
|
||
"description": "Minimal example module",
|
||
"client": "client.js",
|
||
"style": "style.css",
|
||
"minSize": { "w": 2, "h": 1 },
|
||
"defaultSize": { "w": 3, "h": 2 },
|
||
"instanceSchema": {
|
||
"showSeconds": { "type": "boolean", "label": "Show seconds", "default": true }
|
||
}
|
||
}
|
||
```
|
||
|
||
`modules/hello-clock/client.js`
|
||
|
||
```js
|
||
export default function create({ root, moduleApi }) {
|
||
let timer = null;
|
||
|
||
function tick() {
|
||
const { showSeconds } = moduleApi.instanceSettings;
|
||
root.querySelector('.hc-time').textContent = new Date().toLocaleTimeString(
|
||
[], { hour: '2-digit', minute: '2-digit', ...(showSeconds ? { second: '2-digit' } : {}) }
|
||
);
|
||
}
|
||
|
||
return {
|
||
start() {
|
||
root.innerHTML = '<div class="hc-time"></div>';
|
||
tick();
|
||
timer = setInterval(tick, 250);
|
||
moduleApi.setStatus('ok', 'Ticking');
|
||
},
|
||
stop() {
|
||
clearInterval(timer);
|
||
root.innerHTML = '';
|
||
},
|
||
};
|
||
}
|
||
```
|
||
|
||
`modules/hello-clock/style.css`
|
||
|
||
```css
|
||
[data-module="hello-clock"] .hc-time {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 2.4em;
|
||
font-weight: 700;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--text);
|
||
}
|
||
```
|
||
|
||
Drop the folder in `modules/`, restart ProdDash, and “Hello Clock” appears
|
||
in the Add-tile picker (new modules are enabled by default; `/admin` can
|
||
disable them).
|
||
|
||
## Testing your module
|
||
|
||
**Run it:**
|
||
|
||
```bash
|
||
node server.js # from the repo root — prints the LAN URL
|
||
```
|
||
|
||
The server logs each module it mounts (or why it refused to). `/admin`
|
||
shows mount errors and your `health()` line; the browser console shows
|
||
client-side failures (a tile that fails to start says so in the tile).
|
||
|
||
**Poke the pieces directly:**
|
||
|
||
- `GET /api/modules` — what the shell sees (your manifest + non-secret config).
|
||
- `GET /api/modules/<id>/...` — your routes, `curl`-able. For SSE:
|
||
`curl -N http://localhost:24500/api/modules/<id>/stream`.
|
||
- `GET /api/admin/state` — enabled flags, health, mount errors.
|
||
|
||
**Mock upstreams** (so you can develop without the real gear):
|
||
|
||
- ProdCom: `node tools/prodcom-mock.js` (port 24480) — channels, groups,
|
||
history, and a live stream with in-progress speech. Point the
|
||
transcript module's ProdCom URL at `http://127.0.0.1:24480`.
|
||
- ProPresenter: `node band-lineup-display/tmp/pp-mock.js 1599` — playlists
|
||
and an advancing slide index (including the arrangement-repeat case).
|
||
Point the Now/Next module at host `127.0.0.1`, port `1599`.
|
||
- ProPresenter timers: `node tools/pp-timers-mock.js 1600` — running,
|
||
overrunning, stopped and elapsed timers plus an LTC timecode that
|
||
periodically drops to no-signal (`--no-ltc` serves a ProPresenter without
|
||
a timecode API). Also serves the stage-display websocket with an
|
||
LTC-labeled layout field (`--stage-pwd`, `--no-ltc-field` exercise its
|
||
failure paths). Point the Timers module at host `127.0.0.1`, port `1600`.
|
||
- Planning Center: `node tools/pco-mock.js` (port 24700) — folders, service
|
||
types, plans (last week / today / next week), plan times and items with
|
||
songs, keys and item notes, behind a Basic/Bearer auth check. Planning
|
||
Center is a cloud API, so the module reads its base URL from the
|
||
environment instead of admin config: run ProdDash with
|
||
`PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js`, enter any
|
||
Application ID + Secret under PCO Plan in `/admin`, save, then pick
|
||
"Sunday Service" from the Service type list. Its item titles match
|
||
`pp-mock.js`, so running both mocks exercises the ProPresenter matching.
|
||
- AbleSet: `node tools/ableset-mock.js 39071` — a setlist playing through
|
||
over AbleSet's OSC API (UDP): sections, a tempo and meter per song, a
|
||
section that loops twice, a song queued out of order, heartbeats.
|
||
`--stopped`, `--no-live` and `--speed 4` exercise the other states. Point
|
||
the AbleSet module at host `127.0.0.1`, port `39071`.
|
||
|
||
**The resilience checklist** — every module must pass this before it's done:
|
||
|
||
1. Add your tile, then **kill the upstream** (or the mock). The status dot
|
||
must go red (with a useful hover message) and the tile must show a clear
|
||
degraded state, not a freeze.
|
||
2. **Restart the upstream.** The tile must recover by itself — no reload,
|
||
no clicking.
|
||
3. **Restart ProdDash itself** with the tile open. Same expectation.
|
||
4. Add **two tiles of your module** with different per-tile settings — both
|
||
must work independently.
|
||
5. **Remove your tile**, then check the browser console and server log: no
|
||
errors, no timers still firing, no streams still open.
|
||
6. Change your admin config in `/admin` while the tile is open — the tile
|
||
must pick it up (via `onConfigChange` or the automatic remount).
|
||
7. Reload the page — the tile comes back with its settings intact.
|