Files
ProdDash/docs/MODULE-GUIDE.md
T
Claude 54338ddf16 Icon topbar, module title-bar controls, hideable title bars, renames
Topbar: the add button is now just + and the layout button a 3-segment
grid icon (two squares + one tall rectangle). Modules no longer build
their own toolbars — moduleApi.header.addButton/addMenu put controls in
the tile's title bar next to the gear/close (menus are fixed-positioned
by the shell so a tile's overflow can't clip them, and everything a
module adds is removed automatically on unmount). Each title bar grows a
notch on its bottom edge that hides/shows the bar, ProdCom-Listener
style; the choice persists with the layout. Modules renamed: ProdCom and
ProPresenter. The ProdCom tile regains the original listener tools as
icon buttons in its title bar: timestamps toggle (clock), A−/A+ text
size, jump-to-latest, and a circle-with-cross clear button, plus the
Channels menu. MODULE-GUIDE documents the header API; README updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
2026-08-30 16:39:22 +00:00

16 KiB
Raw Blame History

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.

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>/

Installation is exactly this: copy the folder into modules/, restart ProdDash (node server.js), then configure and enable it at /admin. No shell code changes, ever.

module.json

{
  "id": "my-module",
  "name": "My Module",
  "version": "1.0.0",
  "description": "One line shown in the Add-tile picker and the admin page",
  "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, description no Shown in the admin page / picker.
client yes Client entry file, loaded as an ES module.
server no Server entry file (CommonJS), loaded with require().
style no A stylesheet the shell injects once per module.
minSize, defaultSize no Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3.
configSchema no Admin config — server-wide settings (URLs, ports, keys) edited once at /admin for everyone.
instanceSchema no Per-tile settings — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options).

Both schemas map field names to specs:

{ "type": "string" | "number" | "boolean" | "select" | "password",
  "label": "Shown next to the field",
  "default": <value>,
  "options": [ { "value": "a", "label": "A" }, … ]   // "select" only
}

password fields are admin-config only in practice: their values are stored in config/modules.json, 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.

Admin config lives in config/modules.json (written by the admin page — your module never touches that file). Per-tile settings live inside each browser's layout.

Client entry (client.js)

An ES module whose default export is a factory. The shell calls it once per tile:

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.
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.
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:

const btn = moduleApi.header.addButton({
  icon: '<svg …>…</svg>',   // inline SVG string, and/or
  label: 'A+',              // short text
  title: 'Larger text',     // hover tooltip
  onClick() { … },
});
btn.classList.add('active'); // toggle styling for on/off buttons

const menu = moduleApi.header.addMenu({
  label: 'Channels ▾',
  title: 'Choose visible 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. Keep them compact (icon buttons, short labels): the title bar is 32 px tall and shared with the tile's name.

One more thing to know: the user can hide the whole title bar with the small notch on its bottom edge (a clean-view mode for wall displays). Your controls disappear with it, so nothing essential — status, reconnects, live data — may exist only as a header control.

sse(path, handlers) handlers:

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()

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's sse() recreates closed streams on a 3-second timer until they work 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. When the user edits the tile's gear settings the shell calls stop() and starts a fresh instance. When admin config changes, the shell calls your onConfigChange(cfg) if you export one, otherwise it remounts you. Either way you must come back cleanly.
  • 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:

    [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, --danger, --warn. Derive tints with color-mix(in srgb, var(--accent) 20%, transparent). This is what makes every module look native on the dark, high-contrast booth theme.

  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:

module.exports = {
  init({ config, log }) {
    // 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 }) {
    // 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.
  • 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.

Server-Sent Events from a module

For live data, expose a /stream route and let clients use moduleApi.sse('/stream', …):

'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.

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

{
  "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

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

[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:

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.

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.