AB updated Timers from the catalog and LTC died: the update replaces the
module folder wholesale, which deleted the compiled ltc-capture binary —
and AB's Xcode CLT cannot rebuild it (the SwiftBridging modulemap defect;
the module's new self-build correctly runs there and fails with exactly
that error). The binary on AB has always been compiled elsewhere and
shipped, so 'update' was guaranteed to break LTC on that machine.
installModule now carries executables (any execute bit) the download does
not include over from the copy being replaced — or from the app's bundled
copy on a first install — preserving path and mode. Executables only, on
purpose: those are the gitignored per-machine build artifacts (ltc-capture,
spl-capture), while stray text files from an old version — code an update
deliberately removed — must not be resurrected. The Timers module's
staleness check still notices a carried binary older than its updated
source and rebuilds behind it where the toolchain works.
Verified end-to-end against the real Gitea catalog: an installed 1.4.9
copy holding a fake executable and a stray .js file, updated to 1.5.0 —
the executable survives with its mode and content, the stray file is gone,
and the log names what was kept.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AB updated the Timers module to 1.5.0 and its new LTC capture-tool
self-build never ran: mountModule re-required the module's server entry,
and Node's require cache handed back the OLD code — the catalog update
had swapped the folder on disk, but the process kept executing what it
loaded at startup until ProdDash itself restarted. The manifest, read
fresh from disk, meanwhile reported the new version, so the admin page
claimed an update that functionally hadn't happened. Every module update
to date has silently depended on a follow-up app restart.
mountModule now drops the module directory's entries from require.cache
before requiring, so every (re)mount runs the files on disk — nested
requires (ltc-listener.js and friends) included. Modules already must
come up clean on re-init per the module guide, so config-change remounts
see the same contract, minus the trap.
Verified with a probe module in a scratch data dir: change a nested
require on disk, PUT config to remount, the route serves the new code
(it served the stale code before the fix). The Timers module re-inits
cleanly across repeated fresh-code remounts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Twelve columns fit a booth monitor and nothing else well: on a 4K wall a
three-wide tile is nearly a thousand pixels, and on a small screen four tiles
across is the most the grid allows. A stepper in the Layouts menu now cuts
the board into 8, 10, 12, 16, 20 or 24 columns. Row height follows the
column width, so cells keep their shape and a 3×2 tile is still a 3×2 tile,
only smaller — finer lets tiles be smaller so more fit, coarser suits a
screen read close up.
It is this browser's choice, not the server's: two displays at one location
can want different densities, so it saves with the layout — the local one
and a named one on the server — and a named layout comes back at the density
it was arranged on, which the load message says. An older layout with no
density is the default twelve.
Tiles keep their grid units when the density changes; the board simply gets
denser or coarser around them, and a tile now past the right edge is pulled
in. A module's minSize is enforced in cells as declared, so on a finer grid a
tile can be smaller than it could at twelve, which is the point; a new tile's
defaultSize is scaled to the density so it lands at about the size its author
meant. Leaving fullscreen goes back to the chosen density rather than the
stylesheet's twelve — that reset predates any notion of a chosen density.
Shell 1.9.0. The round-trip test carries gridCols and drops a value off the
scale.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The bug behind this: for seventeen days the server rebuilt each saved tile
from the fields it knew and dropped the rest — including `variant`, so a
layout of five different timers came back from the server as five "All
timers". The client had carried `variant` since multi-tile modules arrived;
the server was the one that lagged, and it is the part of ProdDash that
always will (a booth updates weeks after the dev machine). So the server
stops deciding which tile fields exist: it checks the shape of the ones it
knows, keeps any other small plain-JSON field a tile carries, drops only
retired ones, stamps the file `v: 2`, and refuses a layout over a hundred
tiles instead of quietly saving the first hundred. A test round-trips a
fully-populated tile plus one the server has never heard of, and fails if
anything is lost.
Then the rule the arrangement is loaded by. A layout keeps every tile it is
given; the board shows the ones whose module is installed and enabled and —
for a tile added from a picker entry — whose entry is offered right now. The
rest keep their place and draw nothing, and appear the moment the registry
offers them again: a Service timer waits for PCO Plan, a transcript for its
channel. Never the module's default in their place. Because a module's entry
list can change with no event to mark it (PCO Plan comes back and, seconds
later, the timers module is offering its timers again), the registry is
re-read every few seconds while anything is folded away, and not at all
otherwise. The timers module's placeholder card for a missing solo timer
goes with this: nothing is drawn, the dot says why.
Loading says what it did — "Loaded 'FOH': 9 tiles, 2 not shown — Service,
#ops aren't offered right now" — with Undo in the message. Saving over a
name asks first, and the server keeps the copy it replaced one back; Admin →
Saved layouts gets Restore previous, which swaps the two so a restore is one
restore from undone. The browser keeps one good copy of its own layout
behind the live one and falls back to it when the live one will not parse.
Shell 1.8.0, timers 1.4.1. The layouts already saved on the booth machines
had their variants stripped before this and have to be saved again from a
browser that still holds the right tiles — nothing here recovers what the
files no longer contain.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two things the audit found, both about the archive path every shipped .app
uses.
**Settings could be wiped.** The update kept config/modules.json and
config/layouts/ by name, but not config/proddash.json — which holds the admin
PIN, the theme and the forge pointer. On an install whose data directory IS
the app's own config/ (an unwritable Application Support, or PRODDASH_DATA_DIR
aimed inside the app), an update wrote the repo's shipped defaults over it:
the theme went, and admin silently unlocked. Nothing inside the data
directory is written now, wherever it happens to sit, and the two legacy
names stay listed for an install whose state never moved out.
**A bad update was unrecoverable.** No rollback, and a fresh .app would not
help: seeding only replaces an *older* copy, and a broken one carries a
higher version — so the fix was deleting a folder nothing told you about.
Three changes, in the order they catch things:
- The download must parse before anything is swapped in. `node --check`
against the new server.js, and a failure is reported with the syntax error
and leaves the running copy untouched.
- Past that, the copy being replaced is set aside as `app.previous`, and
dropped once the new version has stayed up ninety seconds — so that folder
existing means "the last update has not proven itself yet", and never
anything older.
- The launcher, which already gives up after four stops in two minutes, now
looks for that folder before it does and puts the old copy back. Once: if
the old one will not run either, the update was not the problem.
The launcher half only reaches a machine with a new .app; the server half
travels by the normal update.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven more palettes: Midnight (indigo, violet), Moss (forest floor, lime),
Plum (aubergine, purple), Sepia (warm brown with a cool teal — Ember's
arrangement reversed), Blackout (true black for a dark room, where any glow
off the screen carries), Paper (the warm light one, next to Daylight's cool
white) and Iron, which has no colour of its own: its accent is near-white, so
the only hues on screen are a module's own and the red and amber that mean
something.
Each holds the line the first five set. Text on panel lands between 14.8:1
and 19.8:1, muted between 5.8:1 and 7.4:1, and every accent clears its own
theme's danger red and warning amber by more than Ember already does — the
narrowest of the new ones is Moss at 54 degrees of hue, against Ember's 18.
The Theme section now starts folded, wearing the same disclosure triangle as
a module's config groups. A building picks its palette once and then wants
the rest of the page, which was getting further down the screen with every
swatch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pointing at a tile's top edge already brings its bar back, which left the
notch doing half a job: it could still hide a bar, but hiding one tile at a
time stopped being the interesting question once the whole dashboard could
fold its bars at once. It is gone.
In its place, a pin beside each tile's label. Pinned, that tile keeps its
bar while auto-hide is on — for the one or two tiles whose controls are
wanted at a glance. It sits next to the label rather than out with the gear
and ✕ because it is about the bar, not about the tile's contents, and it
stays faint until the tile is pointed at or the pin is on, so a wall of
unpinned tiles is not a wall of pins.
Fullscreen keeps its own rule: every bar folds, pinned or not, because that
is a mode for watching. What it loses with the notch it gets back better —
the same hover at a tile's top edge peeks a bar there too, and now as an
overlay, so peeking no longer pushes the tile's content down and disturbs
the auto-fit. The peek timer the notch needed goes with it.
Per-tile `headHidden` gives way to `headPinned` in the layout, on disk and
on the server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things about arranging a dashboard.
**Auto-hide bars.** A new button left of ⛶ folds every tile's title bar away
at once, for a windowed screen that should read like a wall display.
Fullscreen already did this for watching; this is the windowed equivalent,
asked for by hand. It starts off and saves with the layout, so a booth's
named layout loads with the bars already tucked away.
**A hidden bar is not a gone bar.** Folded away — by that switch or by a
tile's own notch — the bar leaves the flow and parks just above its tile, so
the module keeps the whole body and nothing reflows as the bar comes and
goes. Point at the tile's top edge and it slides back in over the content,
where its buttons click and its width drags the tile exactly as before. A
dashboard can now be arranged with every bar out of the way, which is the
point of hiding them.
**Every edge resizes.** The top, left and right edges join the bottom one:
each moves only itself, so the tile grows into the space beside it while the
opposite edge stays put, stopping at a neighbour or at the module's own
minimum. Where two tiles meet, that boundary already belongs to the seam
pill between them — it moves both edges and leaves no gap — so the two
tiles' side marks step aside there rather than offer a second, different
control on the same pixels.
One fix underneath: saving a named layout was dropping each tile's variant,
title, minimum size and hidden-bar state, so a saved "Now" tile came back as
the full Now/Next card and a solo timer came back with a full tile's
settings. The layout file now carries them — and the new switch with them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shipped defaults pointed at GitHub, so a fresh install with no
configuration would have looked there — and that repository is going away.
They now name michael/ProdDash on chronicles-ab.waterschurch.org:3000, which
a fresh install was verified to reach with nothing configured at all.
The hostname rather than the IP, because the UniFi DNS record is
authoritative and this survives the NAS changing address. Both production
machines already carry an explicit setting of their own, so neither is
affected either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both sides added to the same two places — the environment-variable list in
server.js and the settings table in the README — because the forge work and
the PIN work each documented a new setting. Everything from both is kept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ProdDash fetched one tar.gz of the branch from GitHub's codeload host, with
that URL shape hardcoded. A `forge` setting now names where the code comes
from: absent or "github" behaves exactly as before, and a Gitea or Forgejo
base URL such as http://10.3.8.90:3000 fetches from a server of your own.
Gitea spells an archive URL differently — /archive/<branch>.tar.gz rather
than /tar.gz/<branch> — and that is the only real difference. Its tarball
carries the same pax header with the same commit hash, so the archive
parser, update detection and module installs are untouched. The "what
changed" link follows the forge too, using each one's own compare and
commits paths.
Verified against both: the same commit and version come back from GitHub
and from a self-hosted Gitea, and a module installs from each.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The passcode machinery existed but could only be set by hand in a config
file, so in practice every install left admin open to anyone on the
network. It is now a PIN you set from the admin page itself.
- Six digits, stored as a scrypt hash in this machine's proddash.json —
never in the clear, since that file gets copied between machines.
- The unlock card takes the PIN and submits itself on the sixth digit.
Five wrong tries from one address locks it out for 30 seconds, and the
right PIN is refused during the lockout.
- Changing or turning off the PIN asks for the current one, so a browser
left open on the admin page cannot be used to take the machine over.
Whoever sets it keeps their session rather than being bounced to the
card they just built.
- This is the shell's own PIN and guards /admin and the admin API only.
Dashboards stay open, and it is unrelated to a module's PIN — Slack's
4-8 digit send PIN still guards sending, separately and independently.
- Ways back in if it is forgotten: PRODDASH_PASSCODE in the environment
(which overrides the PIN), or delete adminPin from proddash.json and
restart. Both are in the README and on the page itself.
- The older free-text adminPasscode still works, so an existing install
keeps running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Browsers allow about six HTTP/1.1 connections to a host and an open
EventSource holds one. Sharing a stream per URL was not enough: a
dashboard with six modules still ran out — Safari first — and every later
request (a remounted tile's /state, an avatar) waited in the browser's
queue for ever; a Timers clock tile went blank after any settings change.
The page now holds ONE EventSource, GET /api/stream?s=<keys>, naming the
streams it wants (`slack:/stream`, `shell:` for the shell's own events).
The server relays each module stream into it over loopback — module
routes are untouched and still serve curl — prefixing every event name
with its key, and brackets each stream's life with `<key>|__open` and
`<key>|__down` (re-opened after a pause). sseSubscribe keeps its shape:
subscribers see their events by their own names, a stream joining the
connection is remade with the new list a moment later, and every stream
announces `open` again after it so backfills run as before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Layouts saved before Timers 1.3.0 hold 'timer:<uuid>' and 'ltc' tiles.
An entry's aliases let those tiles get the entry's slim gear settings
instead of falling back to the module's full schema.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The gear popover lived inside its tile, so a tile could be too small for its
own settings, and the settings sat on top of the thing they change. It is now
a small window: placed beside the tile (right when there is room, else left,
else over it), clamped to the viewport, moved by a grab bar, closed by
clicking away, Escape, the ✕ or the gear. Settings still apply as they
change — that is the point of putting the window beside the tile.
With it, four pieces of contract the module work needs:
- A picker entry may carry its own `instanceSchema` / `instanceGroups`:
tiles added from it get settings that fit that tile, nothing else. A tile
showing one ProPresenter timer has no use for a clock's format switches.
- `instanceGroups[name].toggle` puts a group's on/off switch in its title row
and dims the body while it is off — the admin page's convention, in the
popover.
- `select` and `multiselect` in tile settings may take `optionsRoute`;
`multiselect` also `columns` and `allByDefault` (nothing saved until
someone flips a switch, so options that turn up later are on too).
- `template`: a text with placeholders built by dragging or clicking the
variables a module offers into it.
And the + picker's descriptions are tooltips now — the list stays a list.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two small pieces of contract for modules that offer several tiles from live
data:
- A `tiles()` entry may say `single: true`: at most one such tile per
dashboard. The + picker greys the entry out once it is placed ("Already on
this dashboard") and `addTile` refuses a second — for a control surface
that would only confuse in duplicate, a send box say.
- Admin config gains `multiselect`: several picks from one list, the same
`options` / `optionsRoute` as `select`, one switch per option, stored as an
array of values. A saved value the list no longer offers stays checked and
marked rather than being dropped in silence. The wrapper is a <label>, so
it is told to label nothing — otherwise a click anywhere would flip the
first switch.
Both documented in the module guide.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Server: one THEMES list; `theme` is read from the data directory's proddash.json like port/adminPasscode; GET /api/theme is public (dashboards need it before anyone logs in) and returns { theme, themes: [{ id, name, colors: { bg, panel, accent } }] }; POST /api/admin/theme { theme } validates against the list, writes the key into proddash.json keeping every other key, updates the in-memory value and broadcasts a `theme` shell event over /api/events.
style.css: four html[data-theme] variable sets appended at the end — Harbor (navy/cyan), Graphite (charcoal/blue), Ember (warm black/orange) and Daylight (light, for daytime FOH) — each swapping exactly the eight palette variables; :root stays Booth, the default.
shell.js: a self-contained theme section — the last-known theme (localStorage proddash:theme) goes on synchronously, then the server's answer, then live changes via the SSE event; one listener line added in subscribeEvents. Tile-configured colours are set on the tile and still win.
README: a Themes subsection under Configure and the `theme` row in the proddash.json key table.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four pieces of work are about to run in parallel — the shell, the admin
page, the Timers module and the PCO Plan module — and three of them meet at
things that didn't exist yet. This writes those down first, so they are built
once, the same way.
- **Offering timers to the Timers module.** A module declares
`"provides": ["timers"]` and serves `GET /timers` (a cheap snapshot with
startedAt/elapsedMs/targetMs, an optional status line, optionally an SSE
stream). The consumer extrapolates locally, so no fast polling. Modules meet
over loopback through each other's routes, as PCO Plan already does to
follow ProPresenter.
- **Admin group controls.** `configGroups[name].help` becomes an ⓘ beside the
group title rather than a paragraph under it; `configGroups[name].toggle`
names a switch that renders in the title row as the group's on/off.
- **Switches, labelled with nouns.** On means shown or enabled; the label
shouldn't say so twice.
- **Themes** swap the shell variables; a tile's own configured colour is set
closer and wins.
Two shell hooks make it real: `/api/modules` forwards `provides`, and module
servers get `shell.port` in init/routes/tiles instead of guessing it from
proddash.json.
Clock: descriptions gone, "Show seconds" is "Seconds" (1.0.1).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Resolves the admin form conflicts in favour of this branch's grouped /
collapsible / dependent-select additions (the base hunks were the timers
lane's, ported verbatim), keeps both sides of the README and module-guide
edits, and adopts main's versioning rules: the shell moves to 1.3.0 for the
new schema features (configGroups / instanceGroups, help, color fields,
grouped and dependent selects) and pco-plan 1.3.0 declares
"proddash": ">=1.3.0".
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The gear popover learns the admin form's conventions: an instanceSchema
field's `group` starts a labeled subsection, `help` puts a line of guidance
under a control, and a manifest `instanceGroups[name]` adds
{ collapsed, columns, help } for that group (rendered as <details> with a
grid body; container queries drop to two or one column in narrow tiles).
/api/modules now exposes instanceGroups. Lets a module keep a long
show/hide checklist per tile — where display choices belong — without
the popover turning into a scroll of thirty loose checkboxes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ports the timers lane's admin schema additions unchanged (field `group`,
`showWhen`, type `switch`, `optionsRoute`) so the two branches merge
cleanly, and extends them for modules whose settings are richer than a
handful of fields:
- manifest `configGroups[name]` = { collapsed: true | false | "whenSet",
columns: 2–4, help } — a credentials block that folds once it holds a
saved secret, a show/hide checklist laid out as a compact grid
- `help` on any field: one line of guidance under the control
- select options may carry `group` → rendered as <optgroup>s (a folder
tree browsable inside the select)
- `optionsDependsOn: "<key>"` refetches a select's options with that
field's current value as a query parameter, now and on every change
- a save re-runs the card's dynamic option fetches, so a list that needed
the just-saved credentials fills in without a page reload; a blank
"— choose —" is no longer added when the module offers its own blank
option
Documented in docs/MODULE-GUIDE.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simplify LTC to its one working source — the built-in audio listener — and
make the admin UI reflect it.
Module:
- Remove the stage-display websocket source (stage-ws.js, stagePassword)
and the /v1/timecode/status API probe — both dead on ProPresenter 21.4.
- Remove the remote-reader ingest (POST /ltc, ingestToken, tools/
ltc-reader.js): the module owns LTC end to end now. The decoder selftest
moves to modules/propresenter-timers/ltc.selftest.js so the engine stays
covered.
- composeLtc collapses to the listener: running/stopped/nosignal, or a
clear note when disabled / no device / capture failed / starting.
- Manifest: ltcEnabled is now a `switch`; ltcDevice and ltcChannel carry
showWhen:"ltcEnabled"; ProPresenter and LTC fields carry `group` labels.
Shell (admin) — backward-compatible additions, coordinate with the shell lane:
- New `switch` field type (boolean shown as the enable-toggle control).
- `group`: consecutive fields render under a labeled subsection.
- `showWhen: "<key>"`: a field is shown only while that boolean/switch is
on (and actually collapses — .field[hidden] override, since .field's
display:flex outranks the [hidden] UA default).
- applyConfigPatch coerces `switch` as boolean. Documented in MODULE-GUIDE.
Verified: engine selftest (29 checks); listener decodes BlackHole loopback
(running 30fps, ageMs); device picker lists inputs; POST /ltc now 404s;
admin shows two labeled groups, the switch, and device/channel appearing
only when the switch is on (computed display none↔flex confirmed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ProdDash now has a version (package.json, 1.1.0) and modules declare the
shell versions they need ("proddash": ">=1.1.0" in the manifest). A module
whose requirement this shell doesn't meet is listed in the admin page but
never loaded, and cannot be installed from the catalog.
Modules come from two places: bundled with the checkout (modules/) and
installed from the admin page into the data directory (<data dir>/modules/).
Same id in both → the newer version runs; a downloaded copy the bundled one
has overtaken is removed. The admin page's new "Available modules" section
lists modules in the ProdDash repo that aren't installed here — one small
tar.gz of the branch is fetched (at most every few minutes, only while the
admin page is open or for the periodic update check) and read with a
dependency-free tar reader (ustar, GNU long names, pax headers; the pax
global comment carries the commit sha). Install downloads the module folder
into the data directory and mounts it immediately; a module card offers
"Update to vX" when the repo has a newer version. Uninstall removes an
installed module's files; a bundled module is hidden instead (its folder
belongs to the app) and can be restored from Available modules without a
download. Settings are kept across uninstall/reinstall.
Shell updates: the server compares the repo's package.json version and
commit sha with its own (git rev-parse when the app folder is a checkout),
shows a banner in the admin page, and "Update now" brings the app folder up
to date — git pull --ff-only for a checkout that is on the repo branch with
no local changes (file modes ignored), otherwise the archive is unpacked over
the folder (runtime state and .git untouched). The server then exits with
code 75 and starts its own replacement unless PRODDASH_LAUNCHER is set; the
replacement waits for the port to free up (EADDRINUSE retry). The page polls
until the new version answers. PRODDASH_REPO (owner/repo#branch),
PRODDASH_ARCHIVE_BASE and PRODDASH_NO_REMOTE_CHECK configure/disable it.
Docs: MODULE-GUIDE gets the rules every module follows (all settings in the
admin page, dependencies shipped inside the module, everything runs inside
the module's server entry — no helper programs, declare the ProdDash
requirement and bump the version), the proddash/version manifest fields and
the new installation story; README gets "Installing modules and updating";
CLAUDE.md summarises the rules.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Connection settings, API keys, enabled flags and named layouts were written
to config/ inside the checkout, so a re-clone or reset from main started the
server with nothing configured. They now live in a data directory outside
the app folder:
Windows %APPDATA%\ProdDash
macOS ~/Library/Application Support/ProdDash
other $XDG_CONFIG_HOME/proddash, else ~/.config/proddash
PRODDASH_DATA_DIR overrides the location (a relative path resolves against
the app folder, so "config" restores the old spot). The first start after
upgrading copies an existing config/modules.json and config/layouts/ into
the data directory, so nothing has to be re-entered. If the directory cannot
be created or written, the server warns and falls back to config/ rather
than refusing to start. A proddash.json in the data directory overrides the
checked-in defaults (port, adminPasscode).
The startup banner prints the directory in use, /api/admin/state exposes it,
and the admin page shows it under the Modules heading. Admin config and
layout writes that fail on disk now report the path instead of a generic
server error. README, MODULE-GUIDE and CLAUDE.md updated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
New configSchema type 'endpoint' declared in the module spec: anything
that points at a device or service on the network must be one endpoint
field, rendered identically for every module in /admin as a joined
Host/IP : Port control and stored/handed to the module server as
{ host, port }. Migrates all three connection modules — ProdCom (was a
full URL field), ProPresenter and Timers (were separate host + port
fields). Legacy stored configs (prodcomUrl / flat host+port) keep
working until the admin form is re-saved, at which point the new shape
replaces them. MODULE-GUIDE documents the type and the presentation
rule: no split host/port fields, and full URLs only for genuine web
addresses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
A module may now present a list of tiles instead of a single picker
entry. A server module exports tiles({config, log}) — sync or async,
called on every picker load so the list can be live data (discovered
ProPresenter timers, admin-configured pages); a client-only module can
declare a static tiles array in module.json. The + picker groups the
entries under the module's name; adding one presets that entry's
instance settings, gives the tile the entry's name as its title, and
honors per-entry default/min sizes, all persisted with the layout.
moduleApi.variant tells the instance which entry it was added as. A
throwing or hung tiles() (2 s deadline) falls back to the classic single
entry so a dead upstream can never break the picker; modules without
tiles are byte-for-byte unaffected. Documented in docs/MODULE-GUIDE.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Milestone 6. Layouts CRUD as JSON under config/layouts/ — GET list,
GET/PUT by name (open to every dashboard), DELETE and rename gated by the
admin passcode. Layout menu grows 'Save as named layout…' and a
'Load from server' list; loading copies the layout into the browser's own
local state, never live-links viewers. Admin page lists, renames and
deletes named layouts. Verified with two isolated browser contexts
holding different layouts simultaneously.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Milestone 5. GET /api/admin/state, PUT /api/admin/modules/<id>/config and
/enabled persist to config/modules.json and re-init the module in place —
no restart. /api/events (SSE) tells open dashboards about admin changes;
the shell then calls onConfigChange(cfg) on instances that implement it
and cleanly remounts the rest, so tiles reconnect on their own. Admin
page renders forms from each configSchema (text/number/boolean/select/
password), shows module health from handle.health(), and never echoes
password values back to a browser. Optional shared passcode
(config/proddash.json adminPasscode or PRODDASH_PASSCODE) gates the admin
API behind a cookie; the dashboard API stays open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Milestone 2. Server scans modules/*/module.json, serves module assets at
/modules/<id>/, mounts optional server routes (exact + trailing-/* prefix
patterns) under /api/modules/<id>/ with per-module config from
config/modules.json, and lists enabled manifests at GET /api/modules.
Shell dynamically imports each module's client.js and hands it the
moduleApi (config, instanceSettings, scoped fetch, auto-reconnecting sse,
setStatus). Clock module proves the contract end to end; password-typed
config fields never leave the server.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
Milestone 1 of SPEC.md. Zero-dependency Node server serving public/;
12-column tile grid with add (placeholder tile), drag to move, drag to
resize, remove; collision-free placement; layout autosaves to this
browser's localStorage and survives reload.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH