Twelve columns fit a booth monitor and nothing else well: on a 4K wall a
three-wide tile is nearly a thousand pixels, and on a small screen four tiles
across is the most the grid allows. A stepper in the Layouts menu now cuts
the board into 8, 10, 12, 16, 20 or 24 columns. Row height follows the
column width, so cells keep their shape and a 3×2 tile is still a 3×2 tile,
only smaller — finer lets tiles be smaller so more fit, coarser suits a
screen read close up.
It is this browser's choice, not the server's: two displays at one location
can want different densities, so it saves with the layout — the local one
and a named one on the server — and a named layout comes back at the density
it was arranged on, which the load message says. An older layout with no
density is the default twelve.
Tiles keep their grid units when the density changes; the board simply gets
denser or coarser around them, and a tile now past the right edge is pulled
in. A module's minSize is enforced in cells as declared, so on a finer grid a
tile can be smaller than it could at twelve, which is the point; a new tile's
defaultSize is scaled to the density so it lands at about the size its author
meant. Leaving fullscreen goes back to the chosen density rather than the
stylesheet's twelve — that reset predates any notion of a chosen density.
Shell 1.9.0. The round-trip test carries gridCols and drops a value off the
scale.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 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>
The bug behind this: for seventeen days the server rebuilt each saved tile
from the fields it knew and dropped the rest — including `variant`, so a
layout of five different timers came back from the server as five "All
timers". The client had carried `variant` since multi-tile modules arrived;
the server was the one that lagged, and it is the part of ProdDash that
always will (a booth updates weeks after the dev machine). So the server
stops deciding which tile fields exist: it checks the shape of the ones it
knows, keeps any other small plain-JSON field a tile carries, drops only
retired ones, stamps the file `v: 2`, and refuses a layout over a hundred
tiles instead of quietly saving the first hundred. A test round-trips a
fully-populated tile plus one the server has never heard of, and fails if
anything is lost.
Then the rule the arrangement is loaded by. A layout keeps every tile it is
given; the board shows the ones whose module is installed and enabled and —
for a tile added from a picker entry — whose entry is offered right now. The
rest keep their place and draw nothing, and appear the moment the registry
offers them again: a Service timer waits for PCO Plan, a transcript for its
channel. Never the module's default in their place. Because a module's entry
list can change with no event to mark it (PCO Plan comes back and, seconds
later, the timers module is offering its timers again), the registry is
re-read every few seconds while anything is folded away, and not at all
otherwise. The timers module's placeholder card for a missing solo timer
goes with this: nothing is drawn, the dot says why.
Loading says what it did — "Loaded 'FOH': 9 tiles, 2 not shown — Service,
#ops aren't offered right now" — with Undo in the message. Saving over a
name asks first, and the server keeps the copy it replaced one back; Admin →
Saved layouts gets Restore previous, which swaps the two so a restore is one
restore from undone. The browser keeps one good copy of its own layout
behind the live one and falls back to it when the live one will not parse.
Shell 1.8.0, timers 1.4.1. The layouts already saved on the booth machines
had their variants stripped before this and have to be saved again from a
browser that still holds the right tiles — nothing here recovers what the
files no longer contain.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two things the audit found, both about the archive path every shipped .app
uses.
**Settings could be wiped.** The update kept config/modules.json and
config/layouts/ by name, but not config/proddash.json — which holds the admin
PIN, the theme and the forge pointer. On an install whose data directory IS
the app's own config/ (an unwritable Application Support, or PRODDASH_DATA_DIR
aimed inside the app), an update wrote the repo's shipped defaults over it:
the theme went, and admin silently unlocked. Nothing inside the data
directory is written now, wherever it happens to sit, and the two legacy
names stay listed for an install whose state never moved out.
**A bad update was unrecoverable.** No rollback, and a fresh .app would not
help: seeding only replaces an *older* copy, and a broken one carries a
higher version — so the fix was deleting a folder nothing told you about.
Three changes, in the order they catch things:
- The download must parse before anything is swapped in. `node --check`
against the new server.js, and a failure is reported with the syntax error
and leaves the running copy untouched.
- Past that, the copy being replaced is set aside as `app.previous`, and
dropped once the new version has stayed up ninety seconds — so that folder
existing means "the last update has not proven itself yet", and never
anything older.
- The launcher, which already gives up after four stops in two minutes, now
looks for that folder before it does and puts the old copy back. Once: if
the old one will not run either, the update was not the problem.
The launcher half only reaches a machine with a new .app; the server half
travels by the normal update.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The shipped defaults pointed at GitHub, so a fresh install with no
configuration would have looked there — and that repository is going away.
They now name michael/ProdDash on chronicles-ab.waterschurch.org:3000, which
a fresh install was verified to reach with nothing configured at all.
The hostname rather than the IP, because the UniFi DNS record is
authoritative and this survives the NAS changing address. Both production
machines already carry an explicit setting of their own, so neither is
affected either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both sides added to the same two places — the environment-variable list in
server.js and the settings table in the README — because the forge work and
the PIN work each documented a new setting. Everything from both is kept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
ProdDash fetched one tar.gz of the branch from GitHub's codeload host, with
that URL shape hardcoded. A `forge` setting now names where the code comes
from: absent or "github" behaves exactly as before, and a Gitea or Forgejo
base URL such as http://10.3.8.90:3000 fetches from a server of your own.
Gitea spells an archive URL differently — /archive/<branch>.tar.gz rather
than /tar.gz/<branch> — and that is the only real difference. Its tarball
carries the same pax header with the same commit hash, so the archive
parser, update detection and module installs are untouched. The "what
changed" link follows the forge too, using each one's own compare and
commits paths.
Verified against both: the same commit and version come back from GitHub
and from a self-hosted Gitea, and a module installs from each.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The passcode machinery existed but could only be set by hand in a config
file, so in practice every install left admin open to anyone on the
network. It is now a PIN you set from the admin page itself.
- Six digits, stored as a scrypt hash in this machine's proddash.json —
never in the clear, since that file gets copied between machines.
- The unlock card takes the PIN and submits itself on the sixth digit.
Five wrong tries from one address locks it out for 30 seconds, and the
right PIN is refused during the lockout.
- Changing or turning off the PIN asks for the current one, so a browser
left open on the admin page cannot be used to take the machine over.
Whoever sets it keeps their session rather than being bounced to the
card they just built.
- This is the shell's own PIN and guards /admin and the admin API only.
Dashboards stay open, and it is unrelated to a module's PIN — Slack's
4-8 digit send PIN still guards sending, separately and independently.
- Ways back in if it is forgotten: PRODDASH_PASSCODE in the environment
(which overrides the PIN), or delete adminPin from proddash.json and
restart. Both are in the README and on the page itself.
- The older free-text adminPasscode still works, so an existing install
keeps running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The Theme, Modules, Available modules and layouts sections each opened with a
paragraph explaining themselves. The controls explain themselves; the
paragraphs are gone. The one line that carried information — where settings
are saved on this machine — stays. "Named layouts" becomes "Saved layouts",
which is what they are.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every module's settings were always spread out, so the page read as a wall
of forms. A card now shows what you check at a glance — dot, name, version,
description, status readout, the enabled switch, Uninstall — with one button
at the bottom right: Configure opens the settings and becomes Apply, which
saves without closing them. Clicking the module name opens and closes the
settings too (keyboard as well: it is a focusable button). A card stays open
across a reload for the rest of the session, so re-checking something after
an Apply doesn't fold it away. A module with nothing to configure has no
button.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The module forms' "Save & re-init" becomes "Apply", sitting at the right of
its row with the status text on the left — where a form's action belongs. The
PCO Plan Service timing help was a paragraph behind its ⓘ; it now says the
same thing in three sentences: who owns the clock and when it hands over, how
a service starts and ends, and where the document lists come from.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The Timers ▾ menu's rows are rebuilt only when the row set or a switch
changes, not on every snapshot — rows no longer vanish from under a
click. Their on-state fill uses the shell's accent: the menu hangs off the
title bar, outside the tile root, where the tile's colour properties
don't reach.
- A long timer label ends in an ellipsis (the label is its own box inside
the flex name row).
- The warning window is capped at half a timer's length, so a 20 s
countdown isn't amber for its whole run (it warns under 10 s; a 25 min
one under 2:30).
- Server: the change key that gates SSE frames ignores startedAt (derived
from a whole-second value, it jittered ±500 ms per poll and doubled the
frame rate); ProPresenter errors name the cause (ECONNREFUSED) rather
than fetch's "fetch failed".
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A running service with no live item (not following ProPresenter, nothing
matched yet) now reports "Started 9:00 AM" instead of an empty status,
and the current-item timer is muted and flagged "ProPresenter
unreachable" while the feed or ProPresenter is down — it keeps counting
the last item we knew of, but the wall can tell it is stale.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Brings the admin page's implementation of the contracts this module builds
against: configGroups help as an info glyph, group toggles in the title row,
themes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every boolean (and `switch`) in a tile's gear popover renders as the same
toggle the admin page uses for its module switches — 34×19 track, accent
when on, border track and muted knob when off — with the label on the
left and the switch on the right, so the two surfaces read the same. On
means shown or enabled, off hidden or disabled; the label names the thing.
A real checkbox still sits under the track: Tab reaches it, Space flips it
(with a focus ring), clicking the label text toggles it, and the existing
live-commit wiring reads it unchanged. `switch`-typed instance fields,
which previously fell through to a text input, take the same path.
The popover's bottom was 20 px of nothing: the last field kept its 10 px
margin under the popover's own 10 px padding once the Apply/Cancel row
went. The last child now drops its margin, top and bottom match (11 px
each, border included), and in column layouts the grid gap alone spaces
the rows.
Class names, for the admin page to mirror: `.field.check` (the row),
`.check-label`, `.switch`, `.switch .track`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Add-tile menu would stop appearing once enough tiles were on the
dashboard — a different count each time. The button's handler awaited
GET /api/modules before revealing the menu, and that request could never
complete: browsers allow about six HTTP/1.1 connections per host, an open
EventSource holds one for as long as it lives, and every stream-fed tile
(ProPresenter, Timers, PCO Plan, ProdCom) opened its own. With the shell's
own /api/events that is the whole allowance at five or six such tiles, and
every request after that — the picker's module list, a new tile's /state,
its stream — sat in the browser's queue for a connection that was never
handed back. The count varied because a stream in its 3-second retry loop
frees a connection for a moment, and a clock tile holds none.
Reproduced with six stream-fed tiles: the sixth stream and the picker's
/api/modules both pend forever, and the menu stays hidden after the click.
Three changes, all in the shell:
- Dropdowns open the moment they are clicked, from what the page already
knows, and slot in what their rebuild fetches when it lands. A rebuild
that fails leaves the previous contents rather than an invisible menu,
and a generation token stops a late fetch from repainting a menu that has
since been closed or reopened (the old await also let two quick clicks
cancel each other out). The + picker repaints only when the module list
actually changed; the layout menu shows Save and Reset at once and
inserts the server's layouts above Reset when they arrive.
- Tiles asking for the same stream share one connection. moduleApi.sse()
now subscribes to a shell-wide pool with one EventSource per URL, fanning
events out to every subscriber and reconnecting on the same 3-second
timer while anyone listens; a tile joining an open stream is told `open`
straight away, so its backfill runs as before. Six tiles of three modules
now hold three connections instead of six. Modules see no difference.
- /api/modules and /api/layouts fetches give up after 8 s instead of
pending forever, so nothing awaiting them can hang.
Verified in the browser pane against a scratch server: the same six-tile
layout now opens the picker instantly with all five modules listed and
/api/modules completes; with the pool deliberately starved (three extra
EventSources) the picker still opens from its cached list.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Timers module becomes the one place a production tech looks for every
timer and clock: ProPresenter's timers as before, the LTC timecode, a
time-of-day clock (the Clock module's format / seconds / date, folded in as
a sub-part — modules/clock itself is untouched), and timers other modules
offer over the module guide's provider contract: PCO Plan's service /
current item / countdown, or anything else declaring provides: ["timers"].
Server: a small `sources` abstraction. Each source turns its own world into
the guide's standard timer objects and reports health — ProPresenter (the
existing poller, wrapped, behind ppEnabled), LTC (the existing listener,
untouched, as a kind:"clock" timer whose status.text is the timecode, with
fps/df/ageMs in an `ltc` extension), Clock, and one module source per
provider found in the shell's /api/modules over loopback (shell.port):
/timers/stream when it answers as SSE, else /timers polled every 2 s;
re-discovered every 30 s; backoff 3 s → 15 s; a provider's timers survive a
10 s grace and then go. PCO Plan sits behind pcoEnabled, the rest behind
moduleTimersEnabled. The module is itself a provider: GET /timers and
GET /timers/stream serve the merged set. The header comment says how to
add a platform in a dozen lines.
Client: one tile, every timer, grouped under source headings — label, the
big time derived locally from the snapshot and a server-offset-corrected
clock (so digits tick smoothly between snapshots), a status line, a detail
line. Countdowns count down, elapsed count up, clocks show the time; the
LTC card still counts frames. Feedback colours (running / warning near the
end / overrun / idle) are per-tile colour settings applied as custom
properties on the tile root, so they win over dashboard themes; left at
their defaults they follow the theme. Gear settings: Clock, ProPresenter
timers, LTC timecode, Module timers, Source headings, Status lines, the
clock's format / seconds / date; a "Timers ▾" header menu hides any single
timer (source:id, saved with the layout; the old hiddenItems and
timer:<uuid> / ltc picker variants keep working).
Manifest: 1.3.0, proddash >=1.4.0, provides timers, configGroups with a
toggle switch per integration (ProPresenter / LTC Timecode / PCO Plan /
Other modules) and group help; switch labels are nouns.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The service clock now belongs to one service at a time: a plan's services
take turns by window (the next one takes over pre-roll before its start,
once the previous one has ended or was due to end), and moving into the
next window resets the service elapsed clock, the item timing and the
countdown. A service starts at its scheduled time or, under the new
"Service timing" admin group, when a chosen ProPresenter document goes
live (scheduled time as the failsafe); it ends when the chosen end
document goes live, or at the next service / plan end + 60 min.
The pure logic lives in service-timing.js and matching.js (cue documents
are matched like plan items, with a stricter 0.75 bar so the pre-service
loop never ends a service) with service-timing.test.js covering windows,
precedence and failsafes. GET /timers and /timers/stream offer the
service, current-item and countdown timers per the module guide;
GET /documents lists the documents the Now/Next feed has shown or listed
for the new selects. Loopback calls use shell.port, with the old
proddash.json guess only when the shell hands no port.
Manifest: 1.4.0, proddash >=1.4.0, provides ["timers"]; Client ID
wording, shorter labels, group help tightened, field help trimmed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
configGroups[name].help now renders as an ⓘ beside the group title (.group-info / .group-info-btn / .group-info-pop): a real labelled button — click pins a popover with the text, hover and focus peek at it, Escape or a click elsewhere closes it, and inside a collapsed group's <summary> it never folds the group. The paragraph above the fields is gone; per-field help stays one small line under its control.
configGroups[name].toggle names a switch/boolean field of the group: it moves into the title row, right-aligned (.field-group-toggle), as the group's on/off. While off the body dims to 0.45 (.field-group.is-off) but every field stays editable; the field is not repeated in the body, is saved exactly as before and still drives showWhen. Works with collapsed (the switch never folds the row) and columns. A toggle naming a non-boolean field is ignored.
The module card's status line is a .module-status readout: 1px --border, a background a shade lighter than the card, monospace, small radius and padding, with ok / connecting / error as the text (and border) colour.
New Theme section near the top: five swatches (.theme-swatch with a miniature bg/panel/accent preview), the current one marked, click POSTs /api/admin/theme and applies at once, errors show on a status line; other admin pages' changes arrive live over /api/events, and the login card already wears the server's theme.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Server: one THEMES list; `theme` is read from the data directory's proddash.json like port/adminPasscode; GET /api/theme is public (dashboards need it before anyone logs in) and returns { theme, themes: [{ id, name, colors: { bg, panel, accent } }] }; POST /api/admin/theme { theme } validates against the list, writes the key into proddash.json keeping every other key, updates the in-memory value and broadcasts a `theme` shell event over /api/events.
style.css: four html[data-theme] variable sets appended at the end — Harbor (navy/cyan), Graphite (charcoal/blue), Ember (warm black/orange) and Daylight (light, for daytime FOH) — each swapping exactly the eight palette variables; :root stays Booth, the default.
shell.js: a self-contained theme section — the last-known theme (localStorage proddash:theme) goes on synchronously, then the server's answer, then live changes via the SSE event; one listener line added in subscribeEvents. Tile-configured colours are set on the tile and still win.
README: a Themes subsection under Configure and the `theme` row in the proddash.json key table.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four pieces of work are about to run in parallel — the shell, the admin
page, the Timers module and the PCO Plan module — and three of them meet at
things that didn't exist yet. This writes those down first, so they are built
once, the same way.
- **Offering timers to the Timers module.** A module declares
`"provides": ["timers"]` and serves `GET /timers` (a cheap snapshot with
startedAt/elapsedMs/targetMs, an optional status line, optionally an SSE
stream). The consumer extrapolates locally, so no fast polling. Modules meet
over loopback through each other's routes, as PCO Plan already does to
follow ProPresenter.
- **Admin group controls.** `configGroups[name].help` becomes an ⓘ beside the
group title rather than a paragraph under it; `configGroups[name].toggle`
names a switch that renders in the title row as the group's on/off.
- **Switches, labelled with nouns.** On means shown or enabled; the label
shouldn't say so twice.
- **Themes** swap the shell variables; a tile's own configured colour is set
closer and wins.
Two shell hooks make it real: `/api/modules` forwards `provides`, and module
servers get `shell.port` in init/routes/tiles instead of guessing it from
proddash.json.
Clock: descriptions gone, "Show seconds" is "Seconds" (1.0.1).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Dragging a seam moved exactly the two tiles touching it, so a column edge
shared by several rows had to be dragged row by row and drifted out of line
in between.
A drag now moves that column boundary wherever it appears: every pair of
tiles meeting at the same x, in every row, travels with it. Each pair still
trades width within its own fixed span, so no row can push into another, and
the whole column stops at whichever row runs out of room first — one row's
minimum width holds the line for all of them. All rows move or none do; a
column edge that bends halfway is worse than one that won't budge.
Rows are only grouped by a shared seam. A row split somewhere else keeps its
own boundary, and a row where one tile simply spans across has no seam there
and stays put. Hovering a seam lights up the others that travel with it, so
what is about to move is visible before the drag starts.
Verified in the browser: two rows split at the same column move together from
one drag; rows split at different columns stay independent; a tall tile beside
two stacked ones resizes all three; and a 4-column drag stopped at 1 because
the lower tile's minimum width said so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was a filled rounded pill among two corner brackets drawn as 2px lines —
three marks along the same edge in two different languages. Now it is a 2px
line in currentColor on the same 4px inset, with the same muted-to-text
hover, so the bottom edge reads as one set.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things the dashboard was making people work for.
**The menu handle.** It was the ProdDash wordmark on a pill hanging past the
bar's bottom edge, which read as a step cut out of the bar. The wordmark goes
back in the bar where it belongs, and the handle becomes a small accent lip on
the bar's bottom edge — the same idea as a tile's notch — that grows under the
pointer so it stays easy to hit without being loud at rest.
That also fixes the animation Safari made a meal of: the old handle
transitioned font-size and line-height, animating text metrics frame by frame
on the way out. Nothing here animates text. The lip rides `--topbar-h`, the
bar's live height, so it tracks the bar's own collapse instead of running a
second animation against it, and its hover growth is width, height and
opacity only.
**Fullscreen.** Fullscreen is for watching, not arranging, so every tile's
title bar now folds away on the way in. A notch still peeks one open, and it
folds back ten seconds after the pointer leaves that tile — coming back to the
tile cancels the countdown. Peeking is a transient class, so a tile's own
hidden/shown preference is untouched and comes back with the screen.
**Tile settings.** Gear settings are display choices — a text size, a filter,
a colour — so they apply as they are changed and save with the layout. Apply
and Cancel are gone; close by clicking away, pressing Escape, or the gear
again. Typing is debounced so a remount doesn't land on every keystroke, and
an unchanged value remounts nothing (a text field fires input and then change
for one edit). The module guide says so now too.
Verified in the browser: settings apply live and persist, no-op changes cost
nothing, all three close paths work, and in fullscreen both heads fold, one
peeks, it folds back at ten seconds, returning the pointer cancels it, and
leaving fullscreen restores everything with no peeks left behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Installing is dragging ProdDash to Applications. The app carries the
dashboard and the Node that runs it, so nothing has to be installed first and
nothing can go missing underneath it.
ProdDash.app/Contents/
MacOS/ProdDash the launcher
Resources/node/bin/node Node 24.21.0 LTS, fetched and checksum-verified
Resources/app/ server.js, public/, modules/
Everything mutable stays where it already was, in ~/Library/Application
Support/ProdDash — and that now includes app/, the copy of ProdDash that
actually runs. The copy is not ceremony: ProdDash updates itself in place,
and nothing may write inside a signed .app. So the bundle holds a read-only
baseline and the data directory holds the running copy, which means both
routes to a new version keep working and don't fight — installing a newer
.app replaces the copy, while a copy that has already updated itself past the
bundle is left alone.
build.sh now assembles all of it: fetches Node against nodejs.org's published
checksums (cached in .cache/), embeds the server, compiles each module's
native helper so the Mac running ProdDash needs no compiler, signs nested
code before the bundle, and builds the disk image. --dev skips the payload
for launcher work, which still runs against a checkout.
Signing is set up for a Developer ID with the hardened runtime and
notarisation (--notarize), and falls back to ad-hoc when no such certificate
is installed. Entitlements are split by what each binary actually does: JIT
for node, audio input for the launcher and for module helpers that read one.
Built and run end to end: first-run install, bundled node, dashboard and
admin serving, re-seed in both directions (newer app replaces an older copy;
an updated copy survives), disk image mounts and its app verifies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pass for less prose and fewer boxes.
- Header shows the version and node; the port is already in the addresses
below it, and the module list belonged in the log.
- Settings: "Network" over the port (no explanation under it), "Startup"
instead of "Starting up", and no node chooser — the path stays visible,
since which node runs is the first thing worth knowing when it won't.
- Permissions: one line per permission — a status dot, its name, and the one
thing you can do about it. The standing explanation is gone and the
modules' own reasons moved to each row's tooltip. Local network results
still appear under their row after a check, with a short line and a link
only when nothing answered at all. Re-check sits centred at the bottom,
outside the group.
The app-level "notify me" switch goes with it: macOS's own per-app
notification setting already does that job, so notifications now follow the
permission alone, asked for by the row's Enable button rather than on first
run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A native Swift app (launcher/macos, ~2 MB, no dependencies) that starts,
stops and watches over the server from the menu bar — and holds the macOS
permissions its modules need.
The permissions are the point as much as the buttons are. macOS grants
privacy permissions to the application that owns a process, and a child
inherits its parent's identity, so with the server started from here node —
and the Timers module's ltc-capture — belong to this app bundle and the
microphone and local-network prompts are asked, and remembered, as ProdDash.
Started over SSH or from a launchd job there is nobody to ask: the permission
is silently absent rather than refused, which is the "endless zeroes" failure
ltc-listener.js warns about.
The shell was already built for a supervisor: with PRODDASH_LAUNCHER=1 it
exits 75 instead of respawning itself when the admin page applies an update,
which the launcher reads as a restart request rather than a fault. Crashes
get a backed-off restart (1s, 2s, 5s, 10s) and four in two minutes stops the
cycle. The server is spawned into its own process group, so stopping it also
stops anything it started — an orphaned ltc-capture would otherwise hold an
audio device indefinitely — and signals, a logout and Quit all stop it first.
- Menu bar: ProdDash's own 2x2 tile mark, its accent tile lit green while
serving, amber mid-move, red when something needs a person. Start, stop,
restart, open the dashboard or admin, open at login, quit.
- Window: status and both addresses, plus Log (also written to
~/Library/Logs/ProdDash, kept a week), Settings, and Permissions.
- The port is written to the data directory's proddash.json — ProdDash's own
file — so the port set here holds however it is next started. Something
already on the port is reported, and offered as a takeover when it answers
like a ProdDash started another way.
- Modules declare what they need in their manifests ("permissions": [...],
documented in docs/MODULE-GUIDE.md and ignored by the shell); the launcher
lists exactly that, with each module's reason, asks for the microphone,
and — since macOS reports nothing about local network access — offers a
check that browses for services and tries every configured upstream.
Timers, ProdCom and ProPresenter modules declare their permissions (patch
bumps). Build with launcher/macos/build.sh; output is gitignored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves the admin form conflicts in favour of this branch's grouped /
collapsible / dependent-select additions (the base hunks were the timers
lane's, ported verbatim), keeps both sides of the README and module-guide
edits, and adopts main's versioning rules: the shell moves to 1.3.0 for the
new schema features (configGroups / instanceGroups, help, color fields,
grouped and dependent selects) and pco-plan 1.3.0 declares
"proddash": ">=1.3.0".
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three changes to the ProdCom tile, all per-tile and persisted with the
layout (version 1.2.0):
- A title-bar button flips the transcript between top-down (newest at the
bottom, still the default) and bottom-up. It is CSS only — the entries
container becomes column-reverse, DOM order is untouched — but everything
that follows "latest" flips with it: auto-follow, jump-to-latest, the
near-latest scroll test, and the "New messages" pill, which moves to the
top of the tile and points up.
- The "Channels ▾" text button is now a funnel icon. It keeps what the
label carried: while any channel is hidden the icon takes the accent
color and a compact "3/4" count sits beside it, with the full wording in
the tooltip.
- Tile settings drop the channel-name filter and text size, both of which
the title bar already handles, and gain "Show channel icons" for the
per-entry avatars. Removing the channel filter made its code dead, so
channelFilter(), filteredOut() and the data-channel-name attribute went
with it — the menubar's hidden-channel set is now the only filter.
Text size keeps working without a schema entry: the A−/A+ buttons persist
it through saveInstanceSettings, the shell's settings popover only writes
schema keys so it survives an Apply, and the client falls back to 15px when
it is absent. Existing tiles have no saved showAvatars value and the check
is `=== false`, so they keep their icons.
Verified against tools/prodcom-mock.js: both directions render and
auto-follow correctly, the pill appears at the right end and jumps to the
newest entry, the count badge tracks the channel toggles, a tile bumped to
19px kept it across an Apply and a reload, a freshly added tile started at
15px with icons shown, and two tiles held independent state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section headers lose the bracket look (no accent bar, no rounded corners)
and span the full tile width, with text still aligned to the other rows.
A "Colors" group in the gear menu holds swatch pickers for the song key,
section headers and the current item, a switch for fading the header color
to the right (on by default), and the item-type tint. Colors apply as CSS
custom properties on the tile.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Renders <input type="color"> with the label beside it; the stored value is
always a #rrggbb string (3-digit hex widened, anything else falls back to
the schema default). Same styling in the gear popover and the admin form.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Top bar (template, item-type tint, key color) and the whole show/hide
checklist move from admin config to instanceSchema, grouped in the gear
popover ("Top bar" open, "Show / hide" folded, three columns). They are
browser-side choices stored with the layout, not server state. Admin now
holds only credentials, service type, plan, poll interval and the
ProPresenter follow switch. noteCategories moves to the tile too.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The gear popover learns the admin form's conventions: an instanceSchema
field's `group` starts a labeled subsection, `help` puts a line of guidance
under a control, and a manifest `instanceGroups[name]` adds
{ collapsed, columns, help } for that group (rendered as <details> with a
grid body; container queries drop to two or one column in narrow tiles).
/api/modules now exposes instanceGroups. Lets a module keep a long
show/hide checklist per tile — where display choices belong — without
the popover turning into a scroll of thirty loose checkboxes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Drops the separate setup page: the service type is a select grouped by
Planning Center folder path (GET /service-types), the pinned plan a
select of upcoming/recent plans that follows the chosen service type
(GET /plans?serviceTypeId=), credentials sit in a group that folds once
saved.
- Show/hide moves from per-tile settings to a server-wide checklist in
Admin (three columns, folded by default): top bar, series, part, service
type, date, service time, total length, running clock, countdown,
headers, item numbers, start times, length, type badge, key, arrangement,
author/CCLI, description, notes, pre/post-service items, now indicator,
item runtime, dim completed, ProPresenter line. Tiles keep only text
size and auto-scroll.
- Top bar text is a template with placeholders ({series} • {part} |
{serviceType} • {date} by default; • separates droppable segments, |
starts the second line).
- Song key: larger, admin-chosen color (blue by default), always right
after the title and before the type badge. Section headers are
full-width accent bands. Optional background tint per item type.
- tools/pco-mock.js plan titles are part numbers, as in a real series.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ports the timers lane's admin schema additions unchanged (field `group`,
`showWhen`, type `switch`, `optionsRoute`) so the two branches merge
cleanly, and extends them for modules whose settings are richer than a
handful of fields:
- manifest `configGroups[name]` = { collapsed: true | false | "whenSet",
columns: 2–4, help } — a credentials block that folds once it holds a
saved secret, a show/hide checklist laid out as a compact grid
- `help` on any field: one line of guidance under the control
- select options may carry `group` → rendered as <optgroup>s (a folder
tree browsable inside the select)
- `optionsDependsOn: "<key>"` refetches a select's options with that
field's current value as a query parameter, now and on every change
- a save re-runs the card's dynamic option fetches, so a list that needed
the just-saved credentials fills in without a page reload; a blank
"— choose —" is no longer added when the module offers its own blank
option
Documented in docs/MODULE-GUIDE.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A new Display-group select (Default / Monospace) sets the font for the
timer and LTC digits. Monospace keeps the numbers from shifting sideways
as they change — easier to read on a fast-moving countdown or timecode.
Flows through server state → client applies a `tm-mono` class and re-fits
the digit sizing; both timer and LTC values (.tm-time) honor it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Simplify LTC to its one working source — the built-in audio listener — and
make the admin UI reflect it.
Module:
- Remove the stage-display websocket source (stage-ws.js, stagePassword)
and the /v1/timecode/status API probe — both dead on ProPresenter 21.4.
- Remove the remote-reader ingest (POST /ltc, ingestToken, tools/
ltc-reader.js): the module owns LTC end to end now. The decoder selftest
moves to modules/propresenter-timers/ltc.selftest.js so the engine stays
covered.
- composeLtc collapses to the listener: running/stopped/nosignal, or a
clear note when disabled / no device / capture failed / starting.
- Manifest: ltcEnabled is now a `switch`; ltcDevice and ltcChannel carry
showWhen:"ltcEnabled"; ProPresenter and LTC fields carry `group` labels.
Shell (admin) — backward-compatible additions, coordinate with the shell lane:
- New `switch` field type (boolean shown as the enable-toggle control).
- `group`: consecutive fields render under a labeled subsection.
- `showWhen: "<key>"`: a field is shown only while that boolean/switch is
on (and actually collapses — .field[hidden] override, since .field's
display:flex outranks the [hidden] UA default).
- applyConfigPatch coerces `switch` as boolean. Documented in MODULE-GUIDE.
Verified: engine selftest (29 checks); listener decodes BlackHole loopback
(running 30fps, ageMs); device picker lists inputs; POST /ltc now 404s;
admin shows two labeled groups, the switch, and device/channel appearing
only when the switch is on (computed display none↔flex confirmed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The LTC card repainted only on server anchors (~4x/s), so the frame
counter jumped in ~7-frame steps. Now the client counts every frame
locally between anchors and re-syncs on each one — frame-accurate and
smooth, with no extra network traffic.
- Server anchors carry ageMs (how old the decoded timecode is) so clients
can pin the anchor to their own clock. ltc-listener reports it while
running; composeLtc passes it through (listener) or derives it (reader).
- Client does exact SMPTE frame<->timecode math (parseTc/tcToFrames/
framesToTc, incl. 29.97 drop-frame minute counting) and advances the
display in a requestAnimationFrame loop at the LTC's own rate.
- Robust when rAF is throttled (backgrounded tab): render() paints the
extrapolated frame at the 4x/s anchor rate too, so the display never
freezes — the rAF loop only refines it to every frame when visible.
- Anchor jitter can't tick the counter backwards (small regressions
clamped; genuine seeks/re-strikes pass); freshness cap stops freewheel
into phantom frames when anchors stop arriving.
Verified: SMPTE math against the encoder over 200k drop-frame frames plus
round-trips; the extrapolation pipeline simulated at 30/25/29.97DF showing
strict +1-per-frame advance and correct DF boundary crossing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adopts the new contract: the manifest declares "proddash": ">=1.2.0"
(this module's admin device picker needs the optionsRoute select support
this branch adds), module version 1.2.0, and the shell version bumps
1.1.0 → 1.2.0 for that admin capability. The optionsRoute paragraph
merged into the guide's new schema section cleanly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>