2026-08-30 15:21:05 +00:00
# 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.
2026-09-09 21:12:46 +00:00
## 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.
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.
2026-09-17 14:22:06 -04:00
### 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.
2026-08-30 15:21:05 +00:00
## 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)
└── ... assets (css, images…) served statically at /modules/<id>/
```
2026-09-09 21:12:46 +00:00
**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.
2026-08-30 15:21:05 +00:00
## module.json
```json
{
"id" : "my-module" ,
"name" : "My Module" ,
"version" : "1.0.0" ,
2026-09-09 21:12:46 +00:00
"proddash" : ">=1.1.0" ,
2026-08-30 15:21:05 +00:00
"description" : "One line shown in the Add-tile picker and the admin page" ,
2026-09-11 10:27:08 -04:00
"permissions" : [
{ "kind" : "microphone" , "reason" : "Why this module needs it, in one line" }
],
2026-08-30 15:21:05 +00:00
"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. |
2026-09-09 21:12:46 +00:00
| `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. |
2026-09-11 10:27:08 -04:00
| `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. |
2026-09-13 12:32:31 -04:00
| `provides` | no | Capabilities other modules can consume — `["timers"]` today. Forwarded in `/api/modules` so a consumer can find you. See **Working with other modules** . |
2026-08-30 15:21:05 +00:00
| `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. |
2026-09-22 10:19:49 -04:00
| `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. |
2026-08-30 15:21:05 +00:00
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
2026-09-11 18:40:31 -04:00
| `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. |
2026-08-31 17:53:06 +00:00
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles** . |
2026-08-30 15:21:05 +00:00
Both schemas map field names to specs:
```
2026-09-14 15:20:38 -04:00
{ "type": "string" | "text" | "number" | "boolean" | "switch" | "select" | "multiselect" | "template" | "color" | "password" | "endpoint",
2026-08-30 15:21:05 +00:00
"label": "Shown next to the field",
"default": <value>,
2026-09-10 15:31:12 -04:00
"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
2026-08-30 15:21:05 +00:00
}
```
2026-09-13 12:32:31 -04:00
`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.
2026-09-10 16:31:46 -04:00
`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` ).
2026-09-14 12:56:10 -04:00
`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
2026-09-14 15:20:38 -04:00
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.
2026-09-14 12:56:10 -04:00
2026-09-14 11:49:40 -04:00
`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.
2026-09-10 15:31:12 -04:00
`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.
2026-09-13 12:32:31 -04:00
- `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.
2026-09-10 15:31:12 -04:00
`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
2026-09-17 14:22:06 -04:00
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.
2026-09-10 15:31:12 -04:00
2026-09-17 14:22:06 -04:00
`group` works on `instanceSchema` fields too, and a manifest
2026-09-10 16:20:45 -04:00
`instanceGroups` map plays the `configGroups` role for the tile's gear
2026-09-17 14:22:06 -04:00
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
2026-09-14 16:31:02 -04:00
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
2026-09-15 14:39:35 -04:00
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.
2026-09-14 15:20:38 -04:00
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.
2026-09-10 16:20:45 -04:00
2026-09-10 15:31:12 -04:00
`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.
2026-09-15 09:53:41 -04:00
It works the same in a tile's gear (`instanceSchema` ): "Short day" and
"Short month" appear once the clock's "Date" is on.
2026-09-10 15:31:12 -04:00
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` .)
2026-08-30 15:21:05 +00:00
`password` fields are admin-config only in practice: their values are stored
2026-09-08 14:42:15 +00:00
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
2026-08-30 15:21:05 +00:00
form. Anything upstream that needs the secret must go through your server
routes.
2026-08-31 19:33:08 +00:00
### 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.
2026-09-08 14:42:15 +00:00
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
2026-08-30 15:21:05 +00:00
browser's layout.
2026-09-11 10:27:08 -04:00
### 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. |
2026-09-11 11:10:22 -04:00
`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
2026-09-11 10:27:08 -04:00
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.
2026-08-30 15:21:05 +00:00
## 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. |
2026-08-31 17:53:06 +00:00
| `variant` | For multi-tile modules: the id of the tile-list entry this tile was added as, `''` otherwise (see **Presenting multiple tiles** ). |
2026-08-30 15:21:05 +00:00
| `config` | Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client. |
2026-09-15 14:39:35 -04:00
| `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. |
2026-08-30 15:21:05 +00:00
| `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. |
2026-08-30 16:39:22 +00:00
| `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 ({
2026-09-15 10:49:58 -04:00
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
2026-08-30 16:39:22 +00:00
onClick () { … },
});
btn . classList . add ( 'active' ); // toggle styling for on/off buttons
const menu = moduleApi . header . addMenu ({
label : 'Channels ▾' ,
2026-09-15 10:49:58 -04:00
title : 'Channels' ,
2026-08-30 16:39:22 +00:00
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
2026-09-30 14:06:52 -04:00
// positioned by the shell, hosted on the body under an element carrying
// your data-module, so your scoped CSS applies and nothing clips it)
2026-08-30 16:39:22 +00:00
},
});
menu . setLabel ( 'Channels (2/4) ▾' ); // update the button text any time
```
Everything you add is removed automatically when your instance stops, so a
2026-09-17 14:22:06 -04:00
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.
2026-08-30 16:39:22 +00:00
2026-09-17 14:22:06 -04:00
**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
2026-09-14 16:31:02 -04:00
gear is for display choices that have no button of their own.
2026-09-14 15:28:58 -04:00
2026-09-17 12:04:59 -04:00
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,
2026-08-30 16:39:22 +00:00
live data — may exist *only* as a header control.
2026-08-30 15:21:05 +00:00
`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()
```
2026-09-15 11:04:18 -04:00
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.
2026-08-30 15:21:05 +00:00
### 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` .
2026-09-11 18:40:31 -04:00
- **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.
2026-08-30 15:21:05 +00:00
- **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`,
2026-09-13 14:14:11 -04:00
` --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
2026-08-30 15:21:05 +00:00
` color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes
2026-09-13 12:32:31 -04:00
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.
2026-08-30 15:21:05 +00:00
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 = {
2026-09-13 12:32:31 -04:00
init({ config, log, shell }) {
2026-08-30 15:21:05 +00:00
// 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
};
},
2026-09-13 12:32:31 -04:00
routes({ config, log, shell }) {
2026-08-30 15:21:05 +00:00
// 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.
2026-09-13 12:32:31 -04:00
- ` 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.
2026-08-30 15:21:05 +00:00
- 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.
2026-08-31 17:53:06 +00:00
### 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
2026-09-14 15:40:36 -04:00
description: page.url, // optional; the picker's tooltip
2026-08-31 17:53:06 +00:00
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.
2026-09-14 15:20:38 -04:00
- ` instanceSchema` / ` instanceGroups` on an entry replace the module's for
tiles added from it — settings that fit that tile, nothing else.
2026-09-14 15:40:36 -04:00
- ` 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`.
2026-09-14 15:20:38 -04:00
- Entry ` description`s are tooltips in the + picker now, not visible lines —
one short sentence.
2026-09-14 12:56:10 -04:00
- ` 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.
2026-08-31 17:53:06 +00:00
- 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`.
2026-09-20 12:02:34 -04:00
- **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.
2026-08-31 17:53:06 +00:00
- 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.
2026-08-30 15:21:05 +00:00
### 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.
2026-09-13 12:32:31 -04:00
## 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. |
2026-09-27 08:46:50 -04:00
| ` 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. |
2026-09-13 12:32:31 -04:00
| ` 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.
2026-08-30 15:21:05 +00:00
## 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`.
2026-08-31 13:45:54 -04:00
- 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
2026-08-31 15:21:12 -04:00
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`.
2026-09-09 15:42:53 -04:00
- 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
2026-09-10 15:31:12 -04:00
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.
2026-09-23 14:24:19 -04:00
- 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`.
2026-08-30 15:21:05 +00:00
**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.