Commit Graph
100 Commits
Author SHA1 Message Date
Waters ChurchandClaude Fable 5.1 7481d1d9e2 Shell 1.13.0: the Layouts menu lists the tiles a layout holds but the board is not showing, with a ✕ to remove each
A tile whose module stopped offering its picker entry — Slack's Send message
and Quick replies since the Send Message module took them over — kept its
place in the layout and drew nothing, as designed, but still held its grid
cells with no control anywhere to let them go: a dead area the operator
could not place anything in.

The Layouts menu now has a Not shown section, present only while the layout
holds such a tile. One row per tile: its title (or the module's name plus
the variant, or the module id when it is not installed), why in a few words
— "not offered right now", "module disabled", "module needs a newer
ProdDash", "module not installed" — and a drawn ✕ with a tooltip that
removes it, saves the layout and frees the cells, leaving the menu open as
the Grid Size stepper and Alerts do. The section repaints while the menu is
open whenever tiles become offered or stop being, and only when the list
changed. A tile that merely waits for its module keeps its place as before.

GET /api/modules also returns `inactive` — the installed modules that are
not running, id, name and reason only — so a dashboard can tell a disabled
module from one that is not installed. The toast after loading a named
layout now says the Layouts menu can remove the tiles it did not show.
README and the module guide updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:46:50 -04:00
Waters ChurchandClaude Opus 5 7bb1131378 PCO Plan 1.10.0: follow several service types in priority order, and read any of them from any dashboard
An account runs more than one service type — a Sunday morning, a Wednesday
night, a youth service, a second campus — and until now that meant one
ProdDash per type. The module now follows as many as are configured.

Admin picks them in priority order. Shell 1.12.0 gives `multiselect` an
`orderable: true` that stops being a ticklist: the picks sit in a numbered
list with a nudge either way and a remove, over a dropdown holding what is
left. Forty switches to read past is not a picker, and a ticklist cannot
carry an order anyway. The order is the setting — when two types have a plan
at the same date and time, the one higher up wins.

The server gathers candidate plans from every ticked type and tiers them:
today's plan first, then the next upcoming, then the most recent past, with
the configured order breaking a tie. A type that fails is skipped rather
than failing the read, unless it is an auth or rate-limit answer, which
belongs to the whole connection. The types not being followed are read on a
slower lane (45 s) into `state.others`, so they are there to look at without
a second connection.

Every tile's title bar carries a service-type control. Picking one moves
every PCO Plan tile on that browser's dashboard — plan, teams and song key
together — onto its plan, over a small page bus in `viewed-type.js`; the
choice is per browser and survives a reload. The control hides itself when
only one type is configured.

Live machinery stays with the type being followed: another type's plan shows
its running order with no now marker, no AbleSet and no service clock — it
shows that plan's own scheduled time instead, since borrowing the running
clock of a different service is exactly the misreading the amber top-bar
stripe exists to prevent.

Old configs carry `serviceTypeId`; it is read once into the new array and
saved, and plan pins still resolve bare, so nothing has to be re-entered.

`tools/pco-mock.js` takes `--types N` to pad the list out, for trying the
picker against an account with dozens of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 14:32:19 -04:00
Waters ChurchandClaude Fable 5.1 5ca0a672fa Shell 1.11.0: alerts — a module flashes every dashboard listening on one of six channels
A module that sees something a tech must act on now (a timer overrun, a meter
past its limit, timecode gone) raises an alert on a channel, 1–6, and every
dashboard listening to that channel flashes: the menu bar — brought out for
the duration if it was hidden — and a frame round the screen alternate between
the level's two colours, with the alert's text bold across the bar. Low fades
slowly, medium faster, critical snaps with no fade and floods the board behind
the tiles too, in a muted cut that shows faintly through the tile panels
without costing their content any contrast.

Server: the hub. One alert per channel (a new one replaces the last), timed
out server-side (1–600 s, 15 by default, 0 = held until cleared), broadcast
over the shell stream as `alert` / `alert-clear`; GET /api/alerts for a
dashboard that opens or reconnects mid-alert; POST /api/alerts and
DELETE /api/alerts/<n>, open like a layout save. Modules raise from their
server entry with shell.alert() / shell.clearAlert() or from a tile with
moduleApi.alert() / moduleApi.clearAlert(); a module's alerts end when it
stops. Colours live as alerts.colors in the data directory's proddash.json.

Dashboard: the Layouts menu gains a folded Alerts row with six numbered
toggles; the channel list saves with the layout, local and named (the server
keeps it in a saved layout). Admin: a folded Alerts section with two colour
pickers per level, a swatch running each level's cadence, and a Test row.

Docs: docs/ALERTS.md (the feature), a "Raising alerts" section in the module
guide, README. Tools: tools/alert.js raises/clears/lists from the command
line; tools/alerts.test.js drives it all end to end.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 16:05:12 -04:00
Waters ChurchandClaude Fable 5.1 1274360ee6 Send Message 1.0.0: one PIN, one place to send from; Slack 1.3.0 and ProdCom 1.4.0 become messaging providers
A new send-message module owns everything a dashboard sends: the PIN
(switchable off in admin), the unlock tokens, the Send message and Quick
replies tiles, the quick replies and quick emojis. It opens no upstream
connection of its own — it finds every module declaring
provides: ["messaging"] through the shell's module list and hands each
message to that module's POST /messaging/send over loopback, so Slack keeps
its one Slack connection and ProdCom its one ProdCom connection. Providers
check the browser's unlock token with GET /verify over loopback before
touching their upstream, so no browser on the network can post as the
location without having entered the PIN here.

Slack 1.3.0 is view-only plus reactions: the Sending group, PIN, send tiles
and quick replies are gone; it serves the messaging descriptor (channels as
recipients) and its reactions ride on the Send Message unlock. A layout's
old Send message / Quick replies tiles draw nothing until removed.

ProdCom 1.4.0 serves the descriptor as identities — only channels with
typingEnabled, since typed text goes out *as* a channel — and posts with
POST /api/v1/channels/{id}/send. Its tile is a click-to-target like Slack's
(accent stripe along the bottom), and typed entries carry a keyboard mark.

The tiles: click any Slack or ProdCom transcript to point sends there; a
Send as menu (only for platforms with identities, hidden otherwise) picks
the ProdCom channel to speak as; the choice is saved with the tile so named
layouts remember it. Wording follows the role — "Send message to #ops…" /
"Send message to ProdCom as FOH…". The PIN pad's dots ride on a real
numeric input so a phone's keyboard appears. Outcome is an icon beside
Send (check / blocked, five seconds, sentence as tooltip). A pill on the
box's bottom edge trades box height for rows of quick-emoji keys, a row
at a time; the single row scrolls sideways on a narrow tile with Send
pinned in place.

The module guide documents the provider contract and the page bus
(window.proddashMessaging + proddash:messaging:* events) the tiles share;
the ProdCom mock gained the send route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 13:55:55 -04:00
Waters Church 409f2c258e Merge remote-tracking branch 'origin/main' into claude/update-error-message
# Conflicts:
#	package.json
2026-09-30 14:22:25 -04:00
Waters ChurchandClaude Fable 5.1 41811ead1c Shell 1.10.1: header menus open where their button is, folded bar or not; ProdCom 1.3.3 keeps its filter on a narrow tile
A tile's dropdown menus (ProdCom's Channels, the Timers menu) opened but
never showed under a folded-away title bar — in fullscreen, or with
auto-hide on. The bar carries a backdrop-filter and a transform while
folded, and either one makes a fixed-positioned descendant position
itself against the bar instead of the viewport: the menu landed a
screen-height below its button, inside the tile's overflow. A tile near
the top of the screen showed a misplaced menu; anything lower showed
nothing at all.

The shell now hosts every header menu on the body, under an element
carrying the tile's data-module so a module's scoped CSS still reaches
its items; places it whole inside the viewport; holds a folded bar open
while one of its menus is open (the pointer has to travel down into the
menu); and marks the open menu's button the way the gear is marked.

ProdCom: seven controls need ~175 px of bar, and the shell clips what a
narrow tile cannot fit — the Channels button went with the rest. Below
380 px the six secondary controls fold into a ⋯ menu offering the same
actions, so the filter is the last thing to go.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 14:08:02 -04:00
Waters ChurchandClaude Fable 5.1 0929af46ca SPL Meter 0.5.0: the capture tool ships prebuilt, for Apple silicon and Intel
Same move as Timers 1.7.0. The capture tool existed only where the macOS
app build had compiled it, with the module trying swiftc on the booth as a
fallback — NA has been logging "spawn spl-capture ENOENT" since its
catalog install. Under the module guide's rule it now ships built:
bin/spl-capture-macos-arm64 and bin/spl-capture-macos-x86_64, signed with
the Developer ID (hardened runtime, audio-input entitlement), from
native/spl-capture.swift via native/build.sh. captureTool() picks the
binary for this platform; where there is none — Windows, until a Windows
implementation exists — measurement reports itself off in the health line
and the device picker while the module stays mounted. The runtime build
and its "building…" state are gone, and the old top-level binary is no
longer gitignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 10:12:42 -04:00
Waters ChurchandClaude Fable 5.1 2817a444c3 Timers 1.7.0: the LTC capture tool ships prebuilt, for Apple silicon and Intel
The tool used to be compiled by the macOS app build, or by the module
itself on the booth with swiftc — and a catalog install got source only,
which the unbundled 1.10.0 shell made every fresh install. Under the module
guide's rule it now ships built: bin/ltc-capture-macos-arm64 and
bin/ltc-capture-macos-x86_64, signed with the Developer ID (hardened
runtime, audio-input entitlement), from native/ltc-capture.swift via
native/build.sh. Nothing is built or looked for on the machine any more:
captureTool() picks the binary for this platform, and where there is none
— Windows, until a Windows implementation exists — timecode reports itself
off in the tile and the health line while the rest of the module runs.
The runtime build, its "building…" states and the device picker's build
kick are gone with it. The old top-level binary is no longer gitignored
(it is no longer written).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 10:11:08 -04:00
Waters ChurchandClaude Fable 5.1 c6a44e6ddc Windows build script: executable, like the Mac one
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 09:54:51 -04:00
Waters ChurchandClaude Fable 5.1 2f14b30d68 Windows bundle: no modules either, and the read-me says where they come from
The same change the Mac build got in 1.10.0: the bundle carries the shell
alone; the admin page's catalog installs modules into %APPDATA%\ProdDash.
README.txt gains the step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 09:46:51 -04:00
Waters Church b5d39c60dc Merge remote-tracking branch 'origin/main' into claude/release-builds 2026-09-30 09:46:12 -04:00
Waters ChurchandClaude Fable 5.1 ac1f17b9ed Shell 1.10.0: a packaged ProdDash carries no modules
The macOS app and the Windows bundle shipped every module in the repo, on
by default, and a shell update silently added whatever new module had
landed on main — Band Lineup appeared on AB that way. A module was also
only as fresh as the last shell update. Now a packaged install (no .git —
the same test the updater uses to choose the archive over git pull)
carries no modules at all: every one arrives from the admin page's catalog
into the data directory, and the shell's own update never writes modules/.
A git checkout keeps running the modules in its tree.

Existing installs move over by themselves: at the first start, the bundled
modules that are switched on are copied into the data directory as
installed copies (a compiled helper keeps its mode), an uninstalled one is
dropped, and the app folder's modules/ is removed. Settings are keyed by
module id, so nothing changes for the operator.

Around it: the dashboard's empty state says "No modules yet — open Admin"
when the registry is empty; the admin page says "Nothing installed yet",
puts a checkbox on every compatible catalog entry and an Install selected
button (POST /api/admin/modules/install {ids}) that fetches them in one go.
The Mac build stops copying modules/ and no longer compiles or signs Swift
helpers — those ship prebuilt inside a module, per the guide.

tools/unbundled.test.js drives a packaged copy of the shell through the
move, an archive update that must not bring modules back, and a multi-install.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 09:34:10 -04:00
Waters ChurchandClaude Fable 5.1 07e4c78417 Module rules: a native helper ships prebuilt, for Apple silicon and Windows
The machine running ProdDash is a packaged app or the Windows bundle plus
modules from the admin page's catalog. It has no compiler, and the catalog
delivers exactly the folder that is in the repo — so a module's native
program (audio capture, a decoder) ships already built, inside the module
folder, one file per platform: bin/<name>-macos-arm64 and
bin/<name>-win-x64.exe are required, Intel macOS optional, source and the
script that rebuilds every binary under native/.

The guide's rule 3 now says so, "The shape of a module" shows the layout,
and a new "Native helpers" section gives the contract: pick the binary by
platform at runtime, report the feature off when there is none — never
build, never search the machine, never ask the operator to install
anything — one command-line contract in any language per platform, bump
the version when a binary changes, keep them lean, sign the macOS one.
CLAUDE.md carries the one-line version. Rules only: no module changes here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 16:34:34 -04:00
Waters ChurchandClaude Opus 5 ece2c0a2c3 Centre the NOW tag; the type badge was already fine
Re-measuring the badges properly changed the answer. The earlier reading
that said the type badge sat 0.52px high was measuring the wrong glyphs: it
fed the canvas the element's raw text ("Song", "Media") while the page
renders text-transform: uppercase ("SONG", "MEDIA"), and a lowercase 'd' or
'g' has an ascender and a descender the capitals never draw.

Measured against the text as actually rendered, the type badge is within
0.10px of centred and wants nothing. The NOW tag was the one off, by 0.59px,
the same way the section chips were. So: the badge padding added in the last
commit is reverted, and the NOW tag gets the correction instead.

That line now reads, in px off centre: NOW 0.01, type badge 0.10, key badge
0.16, section chip -0.01.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 14:57:43 -04:00
Waters ChurchandClaude Opus 5 32180bf299 Centre the text in the song-section chips
The chips looked top-heavy, and measuring said so: the capitals sat 5.12px
below the top of the chip and 4.22px above the bottom. Uppercase text has no
descenders, so the font's descent allowance below the baseline is empty
space the letters never use, while the gap between the ascender line and the
cap height sits above them.

The padding is now a touch shallower on top and deeper below, which puts the
letters themselves in the middle: 4.67px above, 4.68px below. In em, so it
holds wherever the tile's text size is set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 14:57:43 -04:00
Waters ChurchandClaude Opus 5 06066529bc Keys read as notes on the Plan tile's badge too
The item badge still showed Planning Center's raw "Bb" and "F#m" while the
Song Key tile beside it showed B♭ and F♯m. One module should not spell the
same thing two ways.

The notation moves into key-notation.js and both tiles import it, rather
than each keeping its own copy of the parse: the day one of them changes,
they all should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 14:57:43 -04:00
Waters ChurchandClaude Opus 5 4e5b60e82b PCO Plan 1.9.0: a Song Key tile, and AbleSet-shaped section chips
**Song Key**, a third entry in the + picker: the key of the item
ProPresenter has live, as large as the tile allows. It is set as a note —
B♭, F♯m rather than Bb and F#m — with the song's name and the next keyed
song under it. All three are tile settings: the colour, the name and the
next key. There is no new connection; it reads the same streamed state the
Plan tile does.

The accidental and the quality sit on the same line as the letter. Raising
the accidental read as two stacked characters at tile sizes, which is worse
than plain at a glance from the back of a room.

When the ProPresenter feed that picks the item goes away the key stays up —
the band still needs it — but faded, and the tile's dot says it is the last
item rather than the current one.

**The section chips** on the Plan tile's current item are rounded squares
now, with the tinted ground and transparent border the AbleSet module gives
its own section pills, so the two read as one family. The chip playing keeps
its own ground so its progress fills against the chip rather than against
the row behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 14:57:43 -04:00
Waters ChurchandClaude Fable 5.1 601dc9e8b0 Update check: say what the forge answered, not zlib's "incorrect header check"
A Gitea that requires a sign-in does not refuse the branch archive: it
redirects the download to its login page, which arrives as a 200 of HTML,
and the admin page then reported zlib failing to unpack it — "incorrect
header check". Both booths showed exactly that on 29 Sept once the sign-in
requirement was switched on.

The updater now names the case: "… requires a sign-in to read
michael/ProdDash@main — turn off 'require sign-in to view' in Gitea, or
make the repo readable without one". Any other answer that is not a gzip
archive says what came back instead — a web page, or the content type —
and where to look.

tools/update-check.test.js drives the check against a mock forge: the login
redirect, a bare HTML answer, an HTTP error, and a real archive that must
still parse and be offered.

Shell 1.9.3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 14:54:05 -04:00
Waters ChurchandClaude Fable 5 86a3270f1c Timers 1.6.0: the ProPresenter video countdown, with looping videos hidden
Time left on the video ProPresenter is playing — the one number a booth
needs during a bumper or sermon video — was missing: the module only read
the configured timers, and the video countdown lives in the transport
routes. Verified live on ProPresenter 21.4:
  GET /v1/transport/presentation/current → { is_playing, uuid, name,
    artist, audio_only, duration }
  GET /v1/transport/presentation/time    → bare seconds as a sub-second
    float — better than /v1/timer/video_countdown's whole-second string,
    so no quantization phase lock is needed, only an anchor hold (a frame
    is broadcast only when the anchor drifts, not per poll).

The Video card rides the ProPresenter source: transport current on the
slow lane, position on the hot path (skipped while no media is loaded),
running/paused/idle from is_playing and position, the media name (minus
its file extension) as the detail line. It persists as an idle '—'
between videos — a wall tile must not appear and vanish with the media —
and is absent only on a ProPresenter whose transport routes 404.

No looping flag exists anywhere in the API, so looping is inferred: the
same media's position jumping backwards is a wrap, and the flag sticks
until the media changes. Each tile gets a 'Looping videos' switch
(default off): off, a looping background's meaningless time is hidden —
the card sits out of a multi-card tile, and a solo Video tile shows a
quiet 'Looping' card instead of sitting empty. Shown loops never take
the warn/overrun tones: a 15 s background flashing red every wrap would
cry wolf all service.

The picker offers 'Video' as its own tile (solo gear carries the loop
switch); pp-timers-mock gains the transport routes with a 4-minute cycle
(bumper playing → paused → resumed → nothing → looping background).
Verified in the browser across the whole cycle, both tile kinds, and the
per-tile toggle. Selftests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 11:32:36 -04:00
Waters ChurchandClaude Fable 5 e9d6edc2c8 SPL Meter 0.4.3: a failed capture-tool build must not park the listener forever
Same deadlock the Timers module hit live on AB (fixed there in 1.5.1):
start() skipped scheduling its retry while a build was in flight, and
ensureCaptureBinary's onReady only fires on build SUCCESS. A failed
build therefore left the listener waiting forever on a callback that
never comes — "Building the capture tool…" indefinitely — and a binary
scp'd in from outside (the required path on AB, whose CLT cannot
compile) was never picked up without a module remount.

onReady stays as the fast path; the 30-second retry is now armed in
every not-ready state, so a failed build keeps re-checking and an
externally supplied binary is found within half a minute.

Reproduced pre-fix (build fails at 0s, binary copied in at 3s, still
untouched at 38s) and verified post-fix (retry at 30s spawns it).
spl-capture --selftest unchanged against the IEC 61672 table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 11:03:57 -04:00
Waters ChurchandClaude Fable 5 62c34fe80c Timers 1.5.1: a failed capture-tool build must not park the listener forever
Found live on AB: the build failed (its CLT is broken), the compiled
binary was then copied in by hand — and the listener never noticed. It
had seen 'build in flight' and stopped scheduling retries, waiting for
ensureCaptureBinary's onReady, which only fires on SUCCESS. The tile said
'Capture tool building…' indefinitely with a working binary on disk; only
a module remount recovered it.

onReady stays as the fast path; the 30 s retry now stays armed in every
not-ready state, so a failed build keeps re-checking and a binary that
arrives from outside (scp'd to a machine that can't compile) is picked up
within half a minute.

Reproduced the exact shape in a swiftc-less environment: build fails,
binary copied in at 3 s, retry at 30 s spawns it (the capture's own
unknown-device answer proves the real binary ran) — previously a
permanent deadlock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 10:55:44 -04:00
Waters ChurchandClaude Fable 5 b0defbd56b Shell 1.9.2: a module update keeps per-machine executables
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>
2026-09-27 10:41:41 -04:00
Waters ChurchandClaude Fable 5 076e583daf Shell 1.9.1: a module update actually runs the updated code
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>
2026-09-27 10:26:38 -04:00
Waters ChurchandClaude Fable 5 8843339f11 Timers 1.5.0: the LTC capture tool installs itself; tiles warn in one line
A catalog install ships source only, and a catalog update deletes the
module folder — a previously built ltc-capture binary with it — so every
install or update left LTC dead until someone compiled the tool by hand.
The build also still used the blocking spawnSync that once froze the whole
server (the bug spl-meter fixed in 0.4.0), and only ran when a listener
started: no tool meant no device list, and no device meant no listener.

ensureCaptureBinary() (module scope in ltc-listener.js, the spl-meter
0.4.2 pattern) now builds the tool in the background as part of coming
up: kicked when the LTC source starts and by the admin device picker,
single-flight, built to a temp name and renamed so a killed build can't
leave a truncated binary, and rebuilt behind a still-serving binary when
a module update leaves the source newer. The waiting listener starts
itself the moment the build lands.

Tile-side warnings are now one line — 'Not configured — see Admin',
'Unreachable', 'Capture tool building…' — with the client's source-label
prefix no longer repeated. The full diagnostics (host:port, errno, the
capture tool's own words, build instructions) move to the admin health
line and the log, where the person who can act on them is looking.

Verified: build from the exact catalog-install state (source only) lands
and lists 12 real devices; touching the source triggers an atomic rebuild
while the old binary keeps serving; /ltc/devices kicks the build and
answers with the device list; live server shows terse source messages on
the tile and the detailed story in /api/admin/state health. ltc and phase
selftests still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 09:47:05 -04:00
Waters ChurchandClaude Opus 5 aec9c47ed4 PCO Plan 1.8.0: the plan follows AbleSet, and auto-scroll stays in its tile
Three things, the first on its own.

**Auto-scroll moves the tile, not the board.** `scrollIntoView` walks up and
scrolls every ancestor that can scroll, so bringing the current item into
view dragged the whole dashboard with it whenever the board was taller than
the window. The list's own `scrollTop` is moved instead, measured from
rectangles because the rows' offset parent is the tile rather than the list.

**The current item fills as its song plays.** When an AbleSet module is
installed, configured and playing a song that IS the item on screen, that
item's background fills left to right with the song. The song is tied to a
plan item by the same name matching the live ProPresenter document already
uses, so a bar can never quietly fill the sermon while the band rehearses
something else.

**Its sections slide past, bottom right of that item.** Every section of the
song as a strip, the played ones dimmed, the one playing filling in turn and
kept centred — by moving that strip's own `scrollLeft`, for the same reason
as above. The chips are rebuilt only when the song changes; moving to the
next section just moves the classes, so the strip keeps its place.

Both are switches in the tile's gear, **Song progress** and **Song
sections**, and both draw nothing at all unless there is an AbleSet module
to follow: the server reports `available: false` for no module, a disabled
one, or one with no host, and re-checks every 15 s so installing AbleSet
later lights it up without a restart.

The server follows the AbleSet module over loopback exactly as it follows
ProPresenter's, and republishes a small slice — song, sections, playhead in
beats with a stamp — as its own `ableset` SSE event. Its own event because
it moves five times a second and must never drag the plan (items, teams)
onto the wire with it; the tile carries the playhead forward from the tempo
between packets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 09:14:19 -04:00
Waters ChurchandClaude Opus 5.5 c1d9c59325 Merge origin/main into claude/proddash-ableset-module-6c2556
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:01:35 -04:00
Waters ChurchandClaude Fable 5 2f4c357bf5 Timers 1.4.2: ProPresenter digits no longer flick back a second
ProPresenter reports running timers as truncated whole seconds, and the
poller re-anchored the tiles' local count at the bottom of that second on
every 500 ms poll — so the displayed digit ran up to a second ahead and
got yanked back a step whenever a fresh poll sampled the next flip later
than the last one did. Tiles flickered ...12:33 → 12:34 → 12:33... about
once a second.

phase.js turns the quantized reports into one stable anchor per timer:
the only information in a poll is a FLIP (the report stepping one second
in the direction of travel), which dates the true boundary to the
midpoint of the two polls that straddle it. While reports stay consistent
with the anchor (±450 ms) the anchor holds, so tiles count along one
steady clock; a report the anchor can't explain (reset, seek, resume,
accumulated drift) re-anchors. Timers carry the anchor as a per-timer
asOf the client extrapolates from (optional in the provider contract,
documented in the guide); the client additionally refuses to tick a
running card backwards over sub-1.5 s jitter.

phase.selftest.js (66 checks) drives a simulated ProPresenter — truncated
reports, jittered polls — through the tracker and the client's render
math: digits never step backwards, dwells hold 1 s (the ~2 s zero-crossing
dwell is trunc's honest behavior), anchors stay within tolerance of the
true value, resets snap, pause/resume re-locks, 0.2% clock drift is
absorbed. Verified live against tools/pp-timers-mock.js: 78 s of 100 ms
DOM sampling across four running cards — every step ±1 in the card's own
direction, dwells 998–1002 ms, zero regressions; upstream kill/restart
still recovers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 08:46:50 -04:00
Waters ChurchandClaude Fable 5.1 b5c539ba05 Release builds: Intel disk images, a Windows bundle, and where to download them
launcher/macos/build.sh takes ARCH=x86_64 for Intel Macs — the Swift target
and the embedded Node follow (nodejs.org calls that one darwin-x64). The
default stays arm64.

launcher/windows/build.sh is new: the same payload plus Node for Windows,
checksum-verified against nodejs.org, and ProdDash.bat, which keeps the
server running and starts it again on exit code 75 — the admin page's update
restart — the way the macOS launcher does. No Windows launcher app yet.

The README's Run it section now points at the Releases page and the
ProdDash-releases repo that holds the files, one line each for Apple
silicon, Intel and Windows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 17:53:19 -04:00
Waters ChurchandClaude Opus 5 6c0aee5abf Teams tile: one Colors group, no Positions switches
Colors and Status colors were two groups asking the same question, so they
are one: team header, the tints and the three status colours together.

The Positions switches go entirely — the team switches already decide what
a tile shows, and "Group by position" still arranges what is left. With
them go GET /positions and positionOptions().

Two rules the tile was breaking on the way past: a gear group may carry no
`help` (rule 5), and only hide-toggles belong in Show (rule 8) — so the
group blurbs are gone and the Columns picker sits loose at the top beside
the team switches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:15:34 -04:00
Waters ChurchandClaude Opus 5 6d0f8828c1 PCO Plan 1.7.0: a Teams tile — who is scheduled, with photos
The module now offers two tiles in the + picker, Plan and Teams, sharing
the one Planning Center connection and the one poll.

Teams draws the current plan's team and position assignments: each team as
a band of its people, with the person's photo, their position and a dot for
confirmed / unconfirmed / declined. Photos are proxied and cached by the
module (GET /team-avatar/<planPersonId>) so no browser ever talks to
Planning Center; a person with no photo gets their initials.

Everything shown is the tile's own choice, so two tiles can differ:
- Teams and Positions — a switch per team and per position, all on until
  someone flips one (GET /teams, GET /positions feed the lists)
- Colours — a picker for the team headers, or Tint teams to give each its
  own, optionally carried into the rows, plus the header fade and pickers
  for the three statuses
- Show — twelve switches (top bar, team headers and counts, avatars,
  positions, group by position, status, notes, declined and unconfirmed
  people, teams nobody is on, columns)

Server: team_members?include=team,person rides a slow lane (re-read on a
plan change, else at most once a minute) and can never fail the plan read —
a lost roster keeps the last good one and says so. The service type's own
team list is read too, cached ten minutes: Planning Center only returns a
team once somebody is scheduled on it, so that list is the only way to show
a team nobody has signed up for.

No shell changes: picker entries carrying their own instanceSchema, the
colour field and multiselects all already exist.

tools/pco-mock.js grows team_members, the service type's teams and stand-in
avatar images, including a declined person, people with no photo and a team
nobody is on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:42:57 -04:00
Waters ChurchandClaude Opus 5.5 9192eed318 AbleSet 1.0.1: a remounted tile asks for the state instead of waiting
Every gear change remounts the tile, and the remounted tile joins the
page's shared stream mid-flow, so it heard nothing until AbleSet next
changed — with the transport stopped, never. Tiles went blank until a
reload. The tile now fetches /state when it starts and whenever the
stream (re)opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 16:07:13 -04:00
Waters ChurchandClaude Opus 5 9313c9e42a SPL Meter 0.4.2: the device picker can build the tool it needs
NA could not list a single audio interface. The module was installed from
the catalog, which ships source only because the capture binary is
gitignored, and the picker shells out to that binary with --list. So it
returned nothing, forever:

  [spl-meter] device list failed: spawn .../spl-capture ENOENT

The missing-binary build existed, but only inside the listener, and the
listener only starts once a device has been picked. No tool meant no device
list, and no device meant no listener to build the tool. Nothing was going
to break that on its own.

The build now lives at module scope and both callers use it: init() asks for
it on mount whether or not a device is configured, and /devices asks again
each time it is called, so opening the admin page is enough to start it. It
is still a plain spawn that never touches the event loop, and health() now
names the situation instead of leaving an empty dropdown unexplained.

Also removed two per-listener build flags left shadowing the module-scope
ones, which would have made the "is a build already running" test always
false and let a second build start under the first.

Tested the way it actually failed: a data directory holding only the six
source files, no binary. The build starts on mount and /devices answers with
the real device list moments later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:25:43 -04:00
Waters ChurchandClaude Opus 5.5 41fd76817c AbleSet 1.0.0: follow AbleSet's setlist over OSC
A new module that subscribes to AbleSet's OSC API (UDP 39051) and streams
what it is playing to ten tiles: Now Playing (song, tempo, meter, duration,
time left, section, progress bar, next song, songs left), Song Sections
(a sliding strip, or a lottery wheel from the title bar), Setlist,
Transport, Tempo, Song Time, Set Time, Section, Next Song and Status, each
coloured by its state. The song and set countdowns are offered to the
Timers module.

A heartbeat watchdog resubscribes when AbleSet goes quiet (a restarted
AbleSet forgets its subscribers). Time left falls back to the song length
and playhead when AbleSet stops sending it (Live disconnected).

tools/ableset-mock.js plays a setlist over the same API for testing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:24:19 -04:00
Waters ChurchandClaude Opus 5 c8b51c6c2f Spectrogram: stop dragging the labels along with the picture
The spectrogram scrolled by copying the visible canvas onto itself one step
along. That canvas already held the frequency labels and gridlines, because
they were drawn straight onto it, so every frame smeared them sideways and
left a trail. Arrived with the labels in 0.3.0; the gridlines did it too.

Only measured pixels belong on a surface that scrolls. The history now lives
on its own offscreen canvas that holds nothing but spectrum, and the visible
canvas is repainted from scratch each frame — history first, annotations
over the top. Nothing drawn as an annotation can reach the scroll buffer by
construction, rather than by remembering to clear the right strip.

The layout was muddled in the same place and is fixed with it: the gutter
used to be reserved along the bottom while the labels were drawn down the
left, overlapping the plot. Labels now sit beside the axis they describe —
down the left when frequency runs vertically, with room for "12.5k", and
along the bottom when it runs horizontally.

Verified both orientations by fingerprinting the gutter and the plot two and
a half seconds apart: gutter identical, plot changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 09:58:32 -04:00
Waters ChurchandClaude Opus 5 c464e5b4ca SPL Meter 0.4.0: a dead input must not look like a quiet room
AB's meter appeared to halt for a day and a half. Nothing had halted: the
capture child had run continuously since the machine was restarted, never
stalled, never exited, and was delivering twenty frames a second. Every one
of them was digital silence, because Dante Via stopped passing audio after
the reboot — the Timers module's LTC listener, on channel 16 of the same
device, had been reporting no signal for exactly as long.

The module's part in that was reporting "ok" through all of it and drawing
a steady -83, which is the -200 floor plus the +117 calibration offset. A
plausible number on a wall is believed, and a green dot beside it says the
number is worth believing. That is worse than showing nothing.

So silence is now a state the module names. Below -150 dBFS unweighted the
input is not quiet, it is absent: a microphone in a silent room still reads
about -83, and even a muted converter dithers. Five seconds of that on every
channel and health() goes to error naming the device and how long it has
been dead, the tiles stop drawing a level and say "No audio" with how long,
and the status dot goes red.

Verified both directions, which is the half that matters: BlackHole with
nothing routed into it is caught in five seconds, and a real microphone in a
quiet room at -49 dBFS is never flagged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 09:54:27 -04:00
Waters ChurchandClaude Fable 5.1 c73d0a3aac PCO Plan 1.6.0: the message document is matched by the plan's own name
The current item followed the live ProPresenter document by title alone,
and a sermon's document is named after the sermon — Waters names them
"YYMMDD Series Title - Part #" — never after its plan item. So the last
song stayed current through the whole message: North Attleboro on 20 Sept
spent 65 minutes on one song in two of its three services.

A live document that reads like the plan itself — its date code, series
title and part — now lands on the plan's message item: an item titled
Message, Sermon or Teaching, else the first item under such a header. A
date code naming another day is a firm no, so last week's sermon stays
unplaced. Position in the ProPresenter playlist breaks a tie between
equal names, only while the playlist mirrors the plan item for item, and
never places a document on its own.

The log now says what it never said: every feed drop and reconnect, which
item the message document will land on, what placed each item and how,
and a live document that is not in the plan.

matching.test.js: 27 checks built from both campuses' real 27 Sept plans
and ProPresenter playlists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 13:06:53 -04:00
Waters ChurchandClaude Fable 5.1 7ca65a845f Grid Size: the stepper's row is a name and a number
The Layouts menu row reads "Grid Size  − 12 +": the label is the setting's
name, the readout is the bare column count, and the default/finer/coarser
caption under the label is gone. The buttons keep their tooltips.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 11:35:26 -04:00
Waters ChurchandClaude Fable 5.1 e383070a9e Grid density: a coarser board settles its tiles instead of stacking them
Stepping the grid coarser pulled tiles past the right edge back in and
left them wherever that put them — on top of one another, which the
board never allows under a drag. Tiles now settle in reading order: one
that would land on a tile already settled takes the first free spot, the
same rule a newly added tile follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 11:26:05 -04:00
Waters ChurchandClaude Fable 5.1 2af7ef27ba Grid density: cut the board finer or coarser, per browser
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>
2026-09-22 10:19:49 -04:00
Waters ChurchandClaude Fable 5.1 5fb90190ae Band Lineup 1.11.0: into the catalog, and to the guide's rules
The module arrived in the previous commit exactly as it runs in North
Attleboro. This brings it to the interface rules every module follows and
puts it where the admin page can offer it.

The three hide-toggles — names, mic labels, parallax — sat in a folded
"Show / hide" group. Rule 8 puts four or fewer at the top of the gear with
no group at all, so they are ungrouped and first; the Birthday group keeps
its switch. The test-mode select loses its help line (rule 5). Everything
else in the gear was already within the rules, and every title-bar control
was already an icon with a tooltip.

The Planning Center mock moves out of the module folder to tools/, beside
the other upstream mocks, so an install does not download a development
tool with the module; README and CLAUDE.md say how to run it. The version
goes to 1.11.0 so the copy already installed on NA is offered the update
from the repo, and the module joins the README's list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:19:34 -04:00
Waters ChurchandClaude Fable 5.1 a7e40ee1a8 Layouts keep every tile they are given, and show what is on offer
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>
2026-09-20 12:02:34 -04:00
Waters ChurchandClaude Opus 5 12f6e22187 An update that goes wrong is no longer the end of it
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>
2026-09-17 16:16:46 -04:00
Waters ChurchandClaude Opus 5 87929bfd0b Don't take the server down when the module unmounts
Deploying 0.3.0 to AB crashed the ProdDash server on restart:

  TypeError: Cannot read properties of null (reading 'streams')
      at server.js:273  req.on('close', () => state.streams.delete(res))

teardown() nulls `state` and then ends every open stream, which fires each
request's close handler — and the handler reached back through the `state`
it had just discarded. Any re-init with a browser connected would do it, so
saving admin config while a dashboard was open was enough to stop the whole
server, every module with it.

The stream handler now closes over this mount's Set rather than the module's
state: after teardown it deletes from a set nobody reads, which is harmless.
The same hazard existed anywhere a callback could outlive the mount — the
keepalive ping, a frame arriving from the listener mid-teardown, and the
/measure interval — and each is closed over or guarded now.

Reproduced first (open an SSE client, save config, watch it fall over) and
confirmed fixed against the same sequence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:06:47 -04:00
Waters ChurchandClaude Opus 5 46d44d494b Keep the banding caveat where the band plan is built
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:00:34 -04:00
Waters ChurchandClaude Opus 5 44fd0b10ba SPL Meter 0.3.0: bring the tiles to the interface rules
Rebasing onto main landed rules 5 to 11, which this module predates and
broke in three places.

Rule 5 — no explanatory text in a tile's gear: the Banding and Smoothing
fields each carried a paragraph. The Banding one said something true and
worth knowing, that below roughly 200 Hz the window length limits real
resolution however fine the setting, so it moves to the source where the
band plan is built rather than being lost. Option labels shortened with it.

Rule 11 — one control per action: the spectrogram offered its axis both as
a gear field and as a title-bar button. The button stays, since that is
what was asked for, and the field is gone. The setting still persists with
the tile; saveInstanceSettings keeps keys that no schema declares.

Rule 9 — title-bar controls are icons: the axis button was drawing a "⇆"
as a label. It is an inline SVG now, with the tooltip carrying the words
and naming the current direction rather than the action.

Rule 8 also asks that a tile can switch off what it draws, so the readout
gained a Caption toggle and both graphs gained Labels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:00:17 -04:00
Waters ChurchandClaude Opus 5 543795f9fd Let the SPL readout fill its tile
The number was capped at 6rem, so a readout given half a wall stopped
growing at the size it reached in a small tile.

Removing the cap was not enough on its own. Sizing text off container units
means choosing a coefficient for some assumed number of characters, and the
value here is not a fixed width: the coefficient that fills a tile nicely
for "-45.9" pushes "-100.5" nine percent out through the side. Measured it
rather than guessed — the tile now sets a trial size, reads the width the
actual text wants, and scales to fit. That runs only when the tile size or
the length of the reading changes, not on every frame.

It now fills about 98% of the width wherever width is the limit and 92% of
the height wherever height is, at every tile size from 180x70 up to
1400x700, with no overflow on either axis. The CSS keeps a conservative
size for the first paint before the measurement has run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:56:27 -04:00
Waters ChurchandClaude Opus 5 d2d4207fe4 SPL Meter 0.2.0: one measurement, many views
Every control asked for here is a view choice, so none of them may reach
the capture child — two dashboards watching the same room must be able to
disagree about how to draw it without disturbing the measurement or each
other. That set the shape of the change: the child now sends everything
anyone might ask for, once, and each tile folds it down.

Averaging (was "Reading") is Fast, Slow, Inf and None, and the child
computes and sends all four every frame. Fast and Slow are the IEC time
constants, Inf is Leq since reset, None is the block with no time constant
at all. The admin-wide time constant is gone — it was never a property of
the room.

Banding is 1/3 through 1/48 octave. The wire always carries 1/48, and
because every banding offered divides 48 a coarser band is an exact sum of
finer ones in the power domain. Nothing is resampled and no tile's choice
is visible to another. The transform grew to 16384 points to have bins fine
enough to mean it; below roughly 200 Hz a 1/48 band is still narrower than
one bin, which the Banding help says out loud rather than pretending.

Each tile kind now carries its own settings, which is the fix for the
complaint that started this: the RTA was offering an alarm threshold and a
metric picker that did nothing to it. An entry's instanceSchema replaces the
module's, so a gear popover only holds controls that change that tile.

Also: frequency labels in three tiers that thin out as a tile narrows
instead of smearing, gridlines off by default on both graphs, RTA smoothing
as a damping factor on the drawn value only, a title-bar toggle to swap the
spectrogram's axes, and the threshold blurb removed.

Settings saved by 0.1.0 are mapped forward — a layout on a wall should not
start reading dashes because a field was renamed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:56:27 -04:00
Waters ChurchandClaude Opus 5 e90ae51495 Never build the capture tool on the event loop
The missing-binary fallback was a spawnSync, copied from the LTC listener
where the source is a third the size and builds in a few seconds. On the
production Mac this file took nearly two minutes, and spawnSync held the
Node event loop for every second of it: one module's optional helper froze
the entire dashboard, every other module with it, and then did it again
thirty seconds later on retry.

The build is now a plain spawn that reports "building" and calls start()
itself when it lands, so nothing waits on it. A failed build says so once
instead of spinning, and a build that never finishes is given up on after
five minutes.

The real lesson is the one in the comment: the binary should already be
there. It is built beside the app, or copied from any Mac of the same
architecture. Building on the machine that might be running a service is
the fallback, not the plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:56:27 -04:00
Waters ChurchandClaude Opus 5 188d1c878d SPL Meter module: measure the room, not just watch it
A sound level meter that lives in ProdDash rather than beside it. Takes an
audio input the way the Timers module's LTC reader does, and offers four
tiles: the level as a big number, per-channel bars with peak hold, a
third-octave RTA, and a scrolling spectrogram.

The measurement happens in spl-capture.swift, not in Node and not in the
browser. LTC gets away with piping raw samples upstream because the decode
is cheap and single-channel; sound level is filtering and FFTs over every
channel at the device's rate, so the child does the work and only the
answers cross the pipe. That costs nothing in dependencies — Accelerate
ships with macOS, and the whole thing builds in two seconds with a plain
swiftc, exactly like ltc-capture.

The weighting filters are the part worth checking rather than trusting, so
`spl-capture --selftest` prints them against the IEC 61672 table. A- and
C-weighting land within 0.15 dB from 31.5 Hz to 4 kHz and exactly 0 dB at
1 kHz. The top octave is where the bilinear transform runs out of room: at
48 kHz the 16 kHz point sits about 3 dB low even with the pole pair
prewarped, and at 96 kHz that falls to 0.6 dB. It is a rounding error on a
broadband number and a real limit all the same, which is why the selftest
prints both rates instead of a pass mark.

Cross-checked against REW on the same microphone: after removing the fixed
calibration offset the two agree to a standard deviation of 0.5 dB on live
room noise.

Uncalibrated is a state the module says out loud — every tile reads dBFS
and labels it, so nobody mistakes a relative number for sound pressure. The
/measure route averages the calibrator tone in the power domain and hands
back the offset to paste into settings; a module cannot write its own admin
config, so that last step stays manual on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:56:27 -04:00
Waters ChurchandClaude Opus 5 12f0b20e5f Rules for a clean interface, and the modules brought to them
Seven rules join the four in the module guide, as rules 5 to 11: no
explanatory text in a tile's gear and no label over three words; server-side
settings in configSchema only; a tile setting changes that tile alone;
hide-toggles ungrouped when there are four or fewer and in a folded "Show"
group when there are five or more; title-bar controls are icons with
tooltips, never names; anything needing words belongs in the gear; and one
control per action, either place but not both.

Two passages said the opposite and now say the same thing — "keep labels
short and put the explanation there" is scoped to configSchema, and "icon
buttons, short labels" becomes icons with no labels at all. CLAUDE.md
carries the summary.

Then the modules. Nothing here changes a setting key or a default, so saved
layouts, named layouts and every tile already on a wall keep working; group
names, labels, help and fold state are presentation and are not stored.

- pco-plan: sixteen labels cut to three words or fewer ("Tint items by type
  (song / media / other)" is now "Tint by type"), "Show / hide" renamed
  "Show" and folded. Its one remaining help line stays by request: the top
  bar's template needs its separator syntax spelled out, which the variable
  chips beside it cannot convey, so rule 5 carves out template fields.
- propresenter-timers: the "Timers ▾" menu was the only named control left
  in any title bar. It is a stopwatch icon now, with the count riding
  alongside it while some timers are hidden — setLabel() writes over the
  button's contents, so the count needed ProdCom's badge treatment rather
  than a label. "Show" folds; the Colours help goes.
- propresenter-now-next: "Show" folds, in the manifest and in the Now
  entry's own copy.
- prodcom-transcript: "Show channel icons" loses the redundant verb.

slack and clock were already compliant and are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:22:06 -04:00
Waters ChurchandClaude Opus 5 f72170b4e1 The folding section reads as a heading first
Theme's title was coming out at the normal weight — <summary> does not
inherit an <h1>'s boldness, so sitting it under .admin-h1 got the size,
colour and spacing but not the type. It now matches the other section
headings exactly.

The caret moved to the right of the words. A marker in front pushes the
heading out of line with every other one on the page; behind it, the section
still reads as "Theme" first and the caret only says there is more.

Written as the pattern rather than as Theme's own styling: any section that
folds is a <details class="admin-fold"> headed by a
<summary class="admin-h1 admin-fold-title">, and the whole row is the target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:22:11 -04:00
Waters ChurchandClaude Opus 5 ea1eac1c28 Twelve themes, and the Theme section folds away
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>
2026-09-17 13:16:47 -04:00
Waters ChurchandClaude Opus 5 ea8be2ac76 Admin PIN: the fields say enough
The paragraph under the PIN fields explained what the PIN covers and how to
clear it by hand — and only someone already past the PIN could read it, so
the recovery note was never where it was needed. Gone, with the dead style
and the element lookup that hung off it.

The live line under the heading stays: it is the only thing that says
whether admin is locked, and the only warning that a passcode in the
server's environment outranks whatever is set here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:05:53 -04:00
Waters ChurchandClaude Opus 5 7f50fa0ba9 A pin on the title bar, and no more notch
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>
2026-09-17 12:04:59 -04:00
Waters ChurchandClaude Opus 5 68f0ba806d Tiles resize from anywhere, so nothing marks the spots
Every edge and both bottom corners resize now, so the little marks that used
to appear on hover were pointing at the whole rim. They are gone — corners,
edges and the seam pill alike — and the cursor over an edge is what says it
can be dragged, the way a window's own edges do it. The seam still shows
itself while it is actually being dragged, which says what is moving rather
than where you may push.

With nothing drawn, the grips could be made to cover the rim properly: the
top strip now runs corner to corner instead of stopping 24px short, the
sides run from it down to the bottom corners, and the bottom strip fills
between those. No dead spots left along an edge.

The side grips also stopped standing down at a seam. They only did so to
avoid stacking three marks in one place, and it cost real ground: a tile
taller than its neighbour lost its whole side, not just the part the seam
covers. The seam sits above them anyway, so it still wins where it exists
and the rest of the edge now works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:52:45 -04:00
Waters ChurchandClaude Opus 5 8e579ecb34 Shell 1.6.0: bars that tuck away, and every edge resizes
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>
2026-09-17 11:39:34 -04:00
Waters ChurchandClaude Opus 5 c379025d30 README: the Slack module joins the list
Every shipped module had an entry under "Included modules" except Slack,
which wasn't mentioned anywhere in the README — so the one module that can
send as the campus was also the one nobody could read about.

Its entry now sits with the others, in their shape: what it does, the tiles
it offers in the + picker (one transcript per configured channel plus the
two single send surfaces), its four admin groups and its tile settings —
all taken from modules/slack/module.json and client.js.

The testing paragraph listed the ProdCom, ProPresenter and PCO mocks but
not the Slack one, though CLAUDE.md has documented it all along; it is now
there with the SLACK_API_BASE line it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:10:03 -04:00
Waters ChurchandClaude Opus 5 f0be37796b Slack 1.2.1: a clearer Send tile, and a send target you can see
The line above the message box said "Sending as Apollo Beach → #ops" with a
Lock button beside it. The box's own placeholder already reads "Message
#ops…" and the title bar already has a Lock, so both went. Quick replies
keeps its line: it has no placeholder, so that is the only place its target
is named.

The bigger thing behind it: until a transcript was clicked, a send went to
the first channel in the admin list whether or not that channel was on the
dashboard — so a board showing only #ops would quietly aim at #general. The
target now falls to the first configured channel whose transcript is
actually up, which with a single transcript is that one. Only when the page
shows no transcript at all does it fall back to the first configured
channel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:58:57 -04:00
Waters ChurchandClaude Opus 5 52d700df3d ProPresenter 1.1.0: a Now tile, and switches for everything on it
The + picker now offers the module two ways. "Now / Next" is the card as it
was. "Now" is the live item on its own: the gradient comes off the card and
fills the tile edge to edge, the border, radius and box go, and the playlist
name and live dot float over the colour instead of sitting above it. A wall
of them reads as panels of colour rather than cards on a background.

Every part of the tile is now a switch in a "Show" group in the gear —
playlist, live status, the NOW and NEXT labels, slide count, progress bar,
the next list and its item tags. One or two words each, no descriptions. The
Now entry carries its own six, since the three Next ones have nothing to act
on there.

Two things the tile still insists on. Trouble overrides the switches: the
top row and the live chip come back the moment the feed drops, whatever is
switched off, because a tile that has stopped updating must never look like
one with nothing live. And at the Now tile's shortest sizes the floating top
row would sit on the title, so below the height it needs it goes — the
header dot and the title itself still say what is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:39:55 -04:00
Waters ChurchandClaude Opus 5 1be5cbef31 The church's own Gitea is where ProdDash lives
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>
2026-09-16 19:00:22 -04:00
Waters ChurchandClaude Opus 5 7690ae1fad Merge main into the admin PIN branch
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>
2026-09-16 18:57:27 -04:00
Waters ChurchandClaude Opus 5 af6322d079 Shell 1.5.0: updates can come from your own git server
The forge setting is a new capability, so the version moves — which is
also how a machine already running 1.4.0 is offered the update at all
(an archive install compares versions, having no commit of its own).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:24:57 -04:00
Waters ChurchandClaude Opus 5 67bcd3640a Updates can come from your own git server
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>
2026-09-16 18:23:20 -04:00
Waters ChurchandClaude Opus 5 6dabd9d8a1 Admin sits behind a 6-digit PIN
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>
2026-09-16 10:46:41 -04:00
Waters ChurchandClaude Opus 5 a3e1282354 No campus's address ships in the repo
prodcom-transcript's endpoint defaulted to 10.3.11.152:24480 — Apollo
Beach's ProdCom. A fresh install anywhere therefore came up reading AB's
comms and reported itself healthy doing it: that is exactly what the new
North Attleboro install did. The default is now blank, so a new install
says "No ProdCom server configured" (in red, as the ProPresenter modules
already do for a missing host) until someone points it at the ProdCom for
that location. Setting a host still connects as before.

The same address is out of SPEC.md's worked example, CLAUDE.md's note
about the reference app, and the prodcom-listener snapshot's config,
README and fallback — that fallback now exits with a line telling the
operator what to set, rather than throwing on an empty URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 10:37:56 -04:00
Waters ChurchandClaude Opus 5 720e5bb590 Shell: every tile can be tinted from its gear
A colour picker at the foot of every gear window washes that tile's
chrome — panel, border and title bar — in a colour of the operator's
choosing, so one screen's tiles can be told apart at a glance. "Clear"
takes it off again. The value saves with the layout under the reserved
key __tint; it is the shell's, not a module's, so picking a colour paints
the tile without remounting the module, and every tile now has a gear
even when its module offers no settings of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:39:35 -04:00
Waters ChurchandClaude Fable 5.1 f29e43373b Timers: cards keep their text when grouped by source
The sizing budget counted rows as if every card sat in one grid unless
headings were showing, so a grouped tile without headings got digits
sized for fewer rows than it had and the lines below were cut off. Rows
now follow the grouping itself; each group's share of the height is the
exact height its rows, gaps and heading take, so every row is the same
height; and after the layout is applied each card's real height is
measured and digits that would still spill are shrunk to fit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:25:37 -04:00
Waters ChurchandClaude Fable 5.1 dde9a00ee1 Timers: "Group by source" on the All timers tile, off by default
Off, every timer sits in one grid filled row by row — a wrapping flex row
rather than a CSS grid, so the last row's cards widen to use the whole
width and nothing is left empty; a source in trouble is named in one line
above the cards, and its own cards dim. On, the sections per source come
back, and "Source headings" appears (showWhen) to hide their titles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:56:22 -04:00
Waters ChurchandClaude Fable 5.1 96eff1a5c5 ProdCom 1.3.0: one connection to ProdCom, shared by every dashboard
The module used to proxy each browser's transcript stream to ProdCom one
to one, so N screens meant N upstream connections and N history reads.
It now follows ProdCom once: it keeps the channel list, the groups and
the last 300 entries, and gives every browser a snapshot on connect
(`state`) plus each changed entry as it happens (`entry`), with `clear`
and `status` frames as the link changes. Reconnects back off from 2 s to
15 s, re-read the lists and history, and re-send the snapshot. The tile
no longer loads anything itself; the admin readout says how many channels
and entries the server holds. The /prodcom/* proxy is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:43:08 -04:00
Waters ChurchandClaude Fable 5.1 ec178e9ccd Guide: streams reach the page over one shared connection
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 11:04:18 -04:00
Waters ChurchandClaude Fable 5.1 680a34ac7a Shell: one stream connection per page
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>
2026-09-15 11:02:36 -04:00
Waters ChurchandClaude Fable 5.1 818def822d Shell: tooltips at once, one or two words; text-size buttons are drawn − and +
Every header and top-bar button's title becomes a tip the shell shows the
moment the pointer arrives (the browser's own waits a second and looks
different everywhere); it moves from `title` to data-tip so the native one
never appears, and a module that sets `title` again is picked up on the
next hover. The shell's own labels are "Settings" and "Remove"; the top
bar's are "Fullscreen", "Add tile", "Layouts", "Admin"; Slack, PCO Plan,
ProdCom and Timers shorten theirs to match. A− / A+ are a drawn − and +
in Slack, PCO Plan and ProdCom, sized like every other header icon.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:49:58 -04:00
Waters ChurchandClaude Fable 5.1 1930fb142d Shell: the Admin button is the cog, icon-only with a tooltip; the + is drawn
Every top-bar button is now an icon with a hover title and no text. The
Admin link uses the same cogwheel as the tile gears; the add button's +
glyph (font-dependent, uneven across browsers) is an SVG plus.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:35:29 -04:00
Waters ChurchandClaude Fable 5.1 eb71bd7b52 Slack: no help line under the Channel notices switch
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:25:27 -04:00
Waters ChurchandClaude Fable 5.1 e37060afff Shell: the tile close button is a drawn cross
The ✕ glyph came from whatever font the browser chose — smaller in
Safari than in Chromium. An inline SVG at the icon size is the same
everywhere and still takes the button's colour on hover.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:24:44 -04:00
Waters ChurchandClaude Fable 5.1 11de9cd150 Slack: an Avatars switch in the transcript's gear
Off, the picture square goes and each row starts at the left edge — the
same behaviour as ProdCom's channel-icon switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:23:02 -04:00
Waters ChurchandClaude Fable 5.1 857a53fec8 Shell: the tile gear is a cogwheel
The settings button's icon is the cogwheel the user supplied, drawn in
the button's colour so it follows hover and the open state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:19:31 -04:00
Waters ChurchandClaude Fable 5.1 fe04627493 PCO Plan: "Starts in" counts days past 24 hours
A service more than a day away reads "2 Days, 05:12:44" (or "1 Day, …")
in the top bar's countdown instead of a growing hour count; under a day
it reads as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 09:58:47 -04:00
Waters ChurchandClaude Fable 5.1 efbcf960dd Timers: the gear chooses what a card shows, not which timers; a 24-hour switch; a date that fits
Which timers appear is the header's Timers ▾ menu's job, so the gear's
source switches (ProPresenter / LTC / module timers, the Clock on/off)
are gone. In their place the Show group offers the card's elements —
Names, Status lines, Detail lines, Seconds, Leading zeros, Source
headings; without seconds a countdown reads in whole minutes, rounded
up. The clock's format select is a 24-hour switch (an older layout's
"12h" choice is honoured); the clock card has no CLOCK label; Short day
and Short month appear once Date is on, and the date fits its card — one
line when it can, else a break after the day and a size that fits the
longer half. Solo tiles get the same element switches.

Shell: the settings window's grab bar is the accent colour and the gear
that opened it wears the same colour while it is open; showWhen works in
a tile's gear as it does on the admin page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 09:53:41 -04:00
Waters ChurchandClaude Fable 5.1 1db6c2c89f Timers: "to 9:30a" under a countdown-to-time, smaller status lines, titles that fit
A ProPresenter count_down_to_time timer's detail line reads "to 9:30a"
(or "to 21:30" on a 24-hour clock) instead of a sentence. The status and
detail lines under the digits are drawn at 0.8 of the title's size, and
fit() budgets them at that share. A title that would be cut off shrinks
to fit its card (measured off-screen, down to 8 px) instead of ending in
an ellipsis; a changed label re-fits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 09:05:56 -04:00
Waters ChurchandClaude Fable 5.1 0acaf47ac7 Slack: transcript rows get their own class; Names switch works with the new rows; notices switch renamed
The row shared its class with the Send tile's status line, so every
transcript row was drawn at 0.8em, muted and nowrap; rows are .sl-row now
and read at the tile's text size. The Names switch targeted the old
markup (name line directly in the row) and hid nothing once the rows
gained a body. "System lines" reads "Channel notices", with a help line
naming what those are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:10:36 -04:00
Waters ChurchandClaude Fable 5.1 cc148926be Slack: log the token's scopes, and which optional ones are missing
Slack names a token's scopes in a header on every answer; the module
now logs them once the token is checked, and says plainly when
reactions:write or emoji:read is absent and how to add it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:58:35 -04:00
Waters ChurchandClaude Fable 5.1 ca696a3931 Slack: a tighter PIN pad, so a three-row tile keeps its keypad
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:50:47 -04:00
Waters ChurchandClaude Fable 5.1 20d4e0f524 Shell: settings window sized to its content, with a slim grab bar
No title and no ✕ — the window sits beside its tile and closes on a click
anywhere else or Escape — and it is as wide as its fields want, so a
couple of switches make a small window. Help lines wrap to the fields'
width instead of widening it.

Guide: one control per action — whatever has a header button is not
offered again in the gear. ProdCom 1.2.2 drops its timestamps and
newest-first gear switches (both are header buttons).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:31:02 -04:00
Waters ChurchandClaude Fable 5.1 5dd1b734aa Slack 1.2.0: emoji everywhere, reactions from the transcript, quick emojis, one PIN per page
Emoji: every standard :code: in a message becomes its character (the full
Slack table ships as emoji.json, from iamcal/emoji-data, MIT); the
workspace's custom emoji come from emoji.list and draw as images proxied
through the module; skin tones work on both.

Reactions: messages show their reactions as pills (the location's own in
the accent). A right-click on a message opens a menu — the admin's quick
emojis, then a search over every emoji — and a click on a pill adds or
takes back the location's reaction. It goes through the same gate as a
send (reactions:write on the app).

Rows follow the ProdCom transcript's layout: a square profile image or
bot icon (initials on a hue tint when there is none), the name line with
its time, the text, the reactions. The accent bar on the location's own
messages is gone.

Send message: the admin's quick emojis sit left of Send, one press sends
one. The PIN pad submits by itself once the PIN's digits are in (the
server sends the length, never the PIN) — no ✓ key; in a tile too short
for the keypad the dots and a hint remain and the keyboard still works.
One PIN unlocks every Slack tile on the page (Send message, Quick replies,
reactions) until auto-lock; a remount keeps it, a reload asks again.

The transcript's gear lists its three switches without a group. The mock
serves reactions.add/remove, emoji.list and profile images.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:31:02 -04:00
Waters ChurchandClaude Fable 5.1 70134d6dfc Shell: a picker entry can name the older variant ids it stands for
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>
2026-09-14 15:40:36 -04:00
Waters ChurchandClaude Fable 5.1 3ceac9599b Merge Timers: each picker entry carries the gear settings that fit its tile
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:39:38 -04:00
Waters ChurchandClaude Fable 5.1 a18d3d35ea Shell: wide fields take the whole row inside a column group
A multiselect's switch grid or a template's chips squashed into one
cell when their group also laid its fields out in columns; they now
span the group's full width.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:37:36 -04:00
Waters ChurchandClaude Fable 5.1 f7a2856267 Merge PCO Plan: note categories as switches, top-bar variable chips, no text-size field
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:37:13 -04:00
Waters ChurchandClaude Fable 5.1 b817e230d4 Timers: each picker entry carries the gear settings that fit its tile
An "All timers" tile keeps the full set — sources, headings, status lines,
the clock's format (now behind the Clock group's title-row switch), the
colours. A solo tile's entry carries only what its one card can use: a
ProPresenter or module timer gets Status lines and the four colours; the
LTC timecode Status lines and the three tones it takes (running, no
signal, stopped); the time-of-day clock its format, seconds and date
alone; a clock another module offers its format plus the idle tone.

Every field is still defined once, in module.json; server.js composes the
subsets from it. The client resolves an absent setting from its own
DEFAULTS rather than from whatever schema the tile has, so a slimmer
schema changes nothing about what a tile shows. Entry descriptions are
one-sentence picker tooltips now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:36:41 -04:00
Waters ChurchandClaude Fable 5.1 0c4828eb26 pco-plan: note categories as switches, top-bar variables as chips, no text-size field
Item notes get their own gear group, after Colors, with the existing
showNotes switch as the group's on/off in its title row. Its categories
come from Planning Center (GET /note-categories → the service type's
item_note_categories, by name, cached 10 min, an empty list rather than
an error when unconfigured or unreachable) and render as switches, three
across, all on until someone flips one; the tile reads an unset value as
every category, an array as exactly those, and a layout's old
comma-separated string as that list (blank = all).

The top bar template is a `template` field: one chip per placeholder
({series} {part} {serviceType} {date} {shortDate} {time} {length}
{items}) to drag or click into the text, with the help down to the
joining rules. Rendering is unchanged.

Text size leaves the gear window: the title bar's A− / A+ already size
the text and save it with the tile; the client falls back to 14 px when
nothing is saved.

tools/pco-mock.js serves item_note_categories (the categories its notes
use plus "Stage", which none does yet) so the route can be exercised.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:35:47 -04:00
Waters ChurchandClaude Fable 5.1 444698c907 Guide: text size is a header control, never a gear field
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:28:58 -04:00
Waters ChurchandClaude Fable 5.1 03d13758a9 Slack: text size is set from the tile header, not the gear popover
The tile header's A− / A+ buttons already size the transcript and
persist through saveInstanceSettings; the gear popover no longer
duplicates that as a number field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:27:49 -04:00
Waters ChurchandClaude Fable 5.1 7a44b25d4b Shell: tile settings in a window beside the tile; schemas per tile kind
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>
2026-09-14 15:20:38 -04:00
Waters ChurchandClaude Fable 5.1 b9e895d324 Slack module: several channels, send tiles follow the transcript clicked last
One Slack tile per channel instead of one channel per machine. Admin picks
any number of the account's channels (`channels`, a multiselect fed by
/channels; a `channel` string saved by 1.0.0 is read as a one-item list, the
module never rewrites admin config). tiles() offers one transcript entry per
channel — id "transcript:<id>", named "#<channel>" so the shell titles the
tile with it, settings { view, channel } — plus Send message and Quick
replies, each `single: true`. A transcript tile from 1.0.0 (no channel in its
settings) shows the first configured channel.

Server: one poller per channel, each with its own window, thread cache,
membership and error, sharing the token, the name caches and the SSE fan-out.
Slack allows an internal app ~50+ conversations.history calls a minute per
token, so the module keeps itself under 40:

  interval per channel = max(pollSeconds, ceil(channels × 60 / 40)) s
  1 → 2 s, 2 → 3 s, 3 → 5 s, 5 → 8 s

with pollers phase-locked across that interval so they never fire in a
burst (first reads staggered 250 ms apart after boot), one auth.test shared
by all of them, and a 429 backing every poller off together. /state returns
`channels` keyed by id (name, isMember, messages, pollAge, error, status)
plus `channelOrder`; /stream stays one `state` snapshot per change. The
health line lists the channels and names any with a problem:
"Apollo Beach · #production, #ops · every 3 s (rate budget) · sending on".
/send and /quick take `channel`, which must be one of the configured ids
(400 otherwise) — a PIN-unlocked browser chooses among the admin's channels,
never an arbitrary one; a quick reply's own "| #channel" wins over it.

Client: a click anywhere in a transcript's body makes its channel the send
target; the chosen transcript carries a 3px accent stripe along the bottom
of the tile body (the chrome is the shell's), the others hint at it on hover.
The selection is one fact about the whole page, so it lives in a deliberate
module-level store in client.js (an EventTarget plus the id, mirrored in
localStorage under proddash:slack:sendTarget so a reload keeps it) — the
comment there says why this is not the per-instance state the guide forbids.
Send message and Quick replies show "Sending as Apollo Beach → #ops" and
follow the selection live; when the selected channel is no longer configured
or its transcript tile is gone, sends fall back to the first configured
channel, and the headers say so. PIN pad, lockout, unlock tokens, Enter to
send, auto-lock, the transcript rendering and the per-tile settings are as
they were.

The mock gains a quiet #ops trickle so two transcripts visibly move at their
own pace; a post to either channel already lands in that channel's history.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 13:28:19 -04:00
Waters ChurchandClaude Fable 5.1 7d34604f2f Shell: single-instance tile entries, and a multiselect admin field
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>
2026-09-14 12:56:10 -04:00
Waters ChurchandClaude Fable 5.1 babe8263eb Slack module: channel transcript + PIN-gated sending as the location
Each campus signs its booth machine into Slack as the account named for
the location, so a message from ProdDash reads as "Apollo Beach" rather
than as whoever is at the desk. modules/slack (1.0.0) is three tiles from
one module: the live transcript of one channel, a free-text Send tile and
a Quick-replies grid — the last two behind a PIN pad, auto-locking after
a configurable idle time.

Server: one user (xoxp-) or bot (xoxb-) token, identified with auth.test;
conversations.history polled every N seconds (thread replies via
conversations.replies, capped per poll), diffed and fanned out as one
`state` SSE snapshot; mrkdwn rendered to safe text on the server; sends
through chat.postMessage (username/icon for a bot with "Send as"); PIN
compared in constant time with a 5-wrong → 30 s lockout, 32-byte unlock
tokens with a sliding expiry, 1 message/s, every send logged; readable
messages for invalid_auth / token_revoked / missing_scope /
not_in_channel / ratelimited; health reflects token → channel → poll →
sending. SLACK_API_BASE points it at tools/slack-mock.js (new).

Shell: the admin config form gains a multi-line field type `text` (a
textarea; one item per line by convention) — used for the quick replies.
Documented in the module guide; the mock is listed in CLAUDE.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 11:49:40 -04:00
Waters ChurchandClaude Fable 5.1 f0fbf8a8f6 Timers: no "Other modules" catch-all in admin
The Timers admin had a fourth group, "Other modules", whose one switch gated
every timer provider that wasn't PCO Plan. A provider that earns a switch
earns a section of its own — so the catch-all goes, any other provider found
is consumed as soon as it appears, and its name (not "Other modules") is what
the health readout lists it under.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 10:51:08 -04:00
Waters ChurchandClaude Fable 5.1 361b8bdaa8 Themes: text on the accent is its own variable; white on Daylight
Primary buttons hardcoded the dark text that reads on a mint accent. On
Daylight the accent is a deep green, and dark text on it manages 3.6:1 —
squinting territory. The colour that sits on the accent is now
`--accent-text`: dark on the four themes where that reads best (7–10:1),
white on Daylight (4.4:1, the better of the two). Modules can use it too;
the guide lists it with the palette.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 14:14:11 -04:00
Waters ChurchandClaude Fable 5.1 bed2212850 Admin: a folded card's readout shares its row with Configure
Folded, the status readout took a full line and Configure sat alone beneath
it. The readout now moves into the bottom row beside the button, the button
staying bottom-right even when a long readout wraps; opening the card puts
the readout back above the form, where it was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 14:10:39 -04:00