Skip to content

Initial release: speaker timer, confidence monitor, docs and release pipeline - #1

Merged
KyleJamesWalker merged 57 commits into
mainfrom
initial-release
Aug 20, 2026
Merged

KyleJamesWalker merged 57 commits into
mainfrom
initial-release

Conversation

@KyleJamesWalker

@KyleJamesWalker KyleJamesWalker commented Aug 20, 2026

Copy link
Copy Markdown
Owner

The first release of SimpleConfidenceMonitor: a speaker timer and confidence
monitor in one Rust binary, with the documentation and the release pipeline
that ship it.

What changed

  • The application. A room per session, a stage display at /<room>, an
    operator console at /<room>/edit, and a read-only agenda. Countdown,
    count-up and time-of-day timers, warning thresholds, overtime, a rundown with
    auto-advance, messages with one-press presets, a second timer, blackout,
    flash, a chime at zero, and per-screen overrides.
  • Operations. One token gates every write, snapshots survive a restart, and
    every operator action is an HTTP call over POST or GET. Optional mDNS
    discovery, off by default.
  • Documentation. docs/ holds operations, architecture, development and
    release, with screenshots of the three screens in the README.
  • CI and release. Six platform binaries, SHA256SUMS, and a
    multi-architecture GHCR image. A check keeps the two build matrices in step.

Per-change rationale is in the commit bodies.

Why

The four products in this space are all hosted services or larger installs. A
small event needs one binary on a laptop, no database and no internet, and a
setup step no longer than opening a URL.

The server owns the clock and each browser estimates its offset from it, so the
digits stay smooth and a display that loses the network keeps counting. That
choice drives most of the rest of the design, and docs/architecture.md records
the alternatives that lost.

Review round

A review of this branch found eleven defects, all now fixed with a regression
test each. The four that could ruin a live session:

  • Editing the cue on air stopped the timer and threw away the speaker's elapsed
    time, because a cue edit reloaded the cue.
  • A cue note containing a newline was destroyed by the documented
    export-edit-import round trip, splitting one cue into two with no error.
  • A running second timer came back from a restart still running, counting the
    downtime, instead of paused.
  • Deleting a room left its sockets live, so one console kept driving a room that
    no reconnecting display could reach.

The rest: an unauthenticated read created rooms without bound, the GET command
API rejected a numeric speaker or note, auto-advance was visible half-applied,
the agenda missed some repaints, the PR image tag never matched what the cleanup
job deletes, a pre-release tag would have failed a published release, CI ran
without a least-privilege token, and the documented docker run silently
disabled the persistence it claimed to provide.

Volunteer testing round

Running the branch for volunteers found more, all fixed here:

  • The console refused a browser without the token instead of asking for it.
    The picker showed a broken QR image before a room had a name.
  • Loading a cue armed the timer without starting it, so the rundown gained a
    next-and-go control and an inline cue editor. Deleting every cue left the
    stage display holding the last title and next-up line.
  • A duration meant milliseconds in JSON and a clock reading in CSV. One rule
    now covers both, and every command: under a thousand means minutes, and a
    clock reading is a clock reading. Deltas stay milliseconds.
  • A snapshot directory the container could not write produced one warning per
    save and an empty directory. That is now fatal at startup, and the docs give
    the ownership recipe for a bind mount.

How it was tested

309 Rust tests and 85 JavaScript tests, clippy -D warnings and cargo fmt
clean against CI's own toolchain (1.97.1), and the matrix-sync check.

Beyond the suites, a release build was driven live: the auth gates, a CSV round
trip through a spreadsheet-style file, snapshot restore across a restart with a
running timer coming back paused, room deletion, and auto-advance stepping a cue
on its own. make soak measured the clock offset at 0.11 ms mean and 1.5 ms
spread on loopback.

The release image was built from a genuine static musl binary and run: the auth
gates behave, it runs as a non-root user, it writes its snapshot into the
mounted volume, and a room survives a container restart when --token is passed
— which is the case the Docker fix was for.

Two things need a real environment rather than this one. The chime needs a
listener, and the clock drift figure needs a venue network. Both are called out
in the docs.

Set up the axum server that the later milestones build on. It carries a
clap CLI, an embedded frontend, a room registry, and a health endpoint.

Room names come from the URL path. RoomName validates a name before it
reaches the filesystem. The rule allows 1 to 64 characters of a-z, 0-9,
dash and underscore, lowercased. It reserves api, assets and healthz.
That keeps a name safe in a URL and safe as a snapshot filename in M5.

The frontend ships inside the binary through rust-embed. The build stays
at one cargo command with no Node toolchain. The plan for a venue laptop
depends on that.

Web assets are placeholders at this stage. M3 and M4 replace them.

Spec: SPEC.md milestone M1.
Add the show clock: timer modes, transport commands, warning phases, and
the room state that clients read.

The readout is a pure function of the timer and a timestamp. No task
ticks a room, so the server runs no timing loop and every case tests at
a fixed instant. A running timer stores the epoch millisecond its
current segment started, and elapsed time accrues across pauses.

Two rules are easy to get wrong, so both carry tests. A zero warning
threshold is off, not a threshold that fires at once. A clock that jumps
backwards saturates to zero elapsed rather than underflowing.

Countdown past zero either counts negative or holds at zero, set per
room by on_expire. Overtime still reports the expired phase in both
cases, which is what turns the viewer red.

Commands carry a serde tag. The same JSON envelope then serves the
socket in M3 and the HTTP API in M5. Room bumps rev only when a command
changed state. That keeps a repeated start from waking every client.

Spec: SPEC.md milestone M2.
Add the sync layer and the first working pair of screens. An operator
opens the console, presses start, and the viewer counts down in step.

The server owns the clock. Every state frame carries server_time_ms, and
each client measures a clock offset from three pings. The browser then
renders from its own animation frame loop and corrects on each frame.
Between frames the digits stay smooth, which polling cannot do.

A room broadcasts its full state, not a diff. The frame runs near 400
bytes, so a diff protocol would buy nothing and would cost reconnect
correctness. Clients also get a frame every 15 seconds, which doubles as
a clock resynchronization.

Each client subscribes before it joins, so it receives its own join
frame. That removes a separate initial send and keeps one code path.
Client counts sit in the frame, so the console can show that a viewer is
connected.

Only a socket opened with role=edit may send a command. A viewer that
tries gets an error frame and the room does not change. Auth arrives in
M5, and this is the structural half of it.

A bad frame returns an error and leaves the socket open. A viewer that
loses the network keeps counting from its last anchor. It warns after
five seconds and reconnects with capped backoff.

shared.js repeats the readout math so the browser can render between
frames. Rust and JavaScript must agree on it, so shared.test.mjs asserts
the same cases as tests/timer.rs. Both suites run through make test.
The node test runner ships with node, so this adds no dependency and no
build step.

Spec: SPEC.md milestone M3.
Add the show features an operator needs during a talk, and the console
that drives them. The set covers notes to the speaker, blackout, flash,
thresholds, title, next up, wall clock, progress bar, mirror and text
size.

Every field of the message and display commands is optional. The console
can change the tone without resending the text, or hide a note without
clearing it. A command that changes nothing leaves rev alone, so a
repeated blackout does not wake the viewers.

Flash is an event, not a state. The command stamps display.flash_at with
the server clock and always counts as a change. A viewer flashes when
the value differs from the one it last saw, so a second flash fires
again. The spec called this a boolean, which cannot express a repeat.

Scale clamps to 50 and 200 percent in the room, not in the browser. Any
client sending the command gets the same bounds.

A state frame arrives on every command, and the naive console overwrites
whatever the operator has half typed. Text inputs now track a draft and
refuse a server value until the two agree. The debounced title send also
captures the value at keystroke time rather than at timer fire time.

Single-letter keys drive the hot path: space, r, b and f. The handler
ignores them while focus sits in a text field.

Verified in a browser against a live server. Both screen toggles and the
timer round trip from console to viewer.

Spec: SPEC.md milestone M4.
Add what the binary needs outside a show. The set covers an operator
token, room snapshots, an HTTP command API, the room picker, and the
README.

One token guards every write. The console and the mutating endpoints
accept it as a bearer header, a query parameter, or a cookie. Only the
query path sets the cookie, because only a browser navigation needs one.
An operator pastes the link once and then navigates freely. Comparison
runs in constant time. Viewer routes never ask for the token, since a
display machine in a booth cannot type one.

The HTTP API takes the same command envelope as the socket. Companion
and a shell script therefore reach every operator action. GET returns
the frame the socket sends, so a polling client can reuse the browser
code.

Snapshots are opt in through --state-dir. A room writes one second after
it settles. The write goes through a temporary file and a rename, so a
crash cannot leave half a file. A snapshot records the save time. A
timer that was running reloads paused at its saved elapsed time, because
a restart means the show already stopped. Loading skips unreadable files
and bad names rather than failing the boot.

The picker builds both room links and a QR code for the viewer URL, and
lists the live rooms.

Room name normalization and link building moved into shared.js so node
can test them. The browser harness could not drive text fields, so the
pure functions carry the tests. The HTTP API verified the rendering
paths instead.

Verified against a live server. State reaches disk, a restart restores
the room with the timer paused, and both browsers reconnect on their
own.

Spec: SPEC.md milestone M5.
Add the running order. A room holds an ordered list of cues, and loading
one points the timer and the screen at it. Next and previous walk the
list. Auto advance starts the following cue when a cue runs out.

A cue id is never reused. Remove a cue and add another, and the new one
gets a fresh id. A console holding a stale list therefore cannot load
the wrong cue.

Loading a cue resets the run and sets the duration. It also writes the
cue title and the following title onto the screen.

Auto advance needs to notice zero, which no other part of the server
does. The readout stays a pure function of the timer and a timestamp. A
200 millisecond scan handles this one case instead. It only touches
rooms with auto advance on and a running countdown. Nothing about the
readout depends on it.

The last cue does not advance. It runs into overtime and stays there,
which is what an operator wants at the end of a session.

The console gains a rundown panel. It marks the active cue in the list.
Each row carries load, reorder and remove. Below sit an add form and the
transport. The header compares the time left in the plan against its
total. Keys n and p step the list.

Verified against a live server. A four second cue advanced on its own,
carried the title across, and started the next cue running.

Spec: SPEC.md milestone M6.
Bring the spec in line with the code. It was a proposal, and six
milestones later three parts of it no longer matched.

Flash is an event, not a boolean. Auto advance runs a narrow scan, which
the original design ruled out for the readout and still does. The
frontend carries a node test suite that the design did not plan for.

Also add the endpoints M5 and M6 introduced, and the Rundown state.

A stale design doc is worse than none, and this one still has to explain
why the alternatives lost.
Record what comes after the six milestones, ordered by what an operator
reaches for most. The top unchecked item is next, and each item is one
commit.

The list separates features from polish, and keeps the reason for each
item next to it. Three items came out of driving the app. Those are the
toggle styling collision, the wrapping nudge row, and the missing
reduced motion support.
Put the common notes to the speaker one press away. A room carries a
short list of presets, and the console shows one button per preset above
the message box.

Typing is the thing an operator has no hands for during a talk. Every
product in the survey carries presets for that reason.

A preset holds text and a tone, so pressing one sets both and shows the
note. The room owns the list rather than the browser. Every console sees
the same buttons, and a snapshot keeps them across a restart.

send_preset is one command, so a Stream Deck button reaches it through
the HTTP API. Sending the same preset twice leaves rev alone.

set_presets replaces the list. It drops empty entries and trims text at
120 characters. It keeps at most 8, because the row has to stay readable
on a tablet.

Verified against a live server. One click in the console put the note on
the viewer with its tone.
Give a room an audible end. The viewer plays three short beeps as the
timer crosses into overtime. A speaker facing away from the screen still
notices that.

The chime starts off. A room with three viewers should not beep three
times, so an operator turns it on where it belongs. One screen can carry
it alone with ?sound=1 while the room setting stays off.

Only the crossing rings. A viewer that joins a room already in overtime
stays silent, because a chime then reports nothing new. That rule is one
function with its own tests, since it is the part worth getting right.

A browser blocks sound until someone interacts with the page. The viewer
therefore builds its audio context on the first click, key or touch. It
also shows a button to tap while the chime is on and sound stays locked.
Without that hint an operator cannot tell a silent screen from a broken
one.

WebAudio generates the tone, so the binary carries no audio file. The
envelope ramps both ends, or the speaker clicks.

Verified against a live server. The hint appeared when the chime went on
and a click cleared it. The timer crossed zero with no console errors.
The tone itself needs a real listener.
Stop the moving parts for anyone who asked the system for less motion.
The overtime blink and the flash both animate. A flashing screen is the
exact thing that setting exists to stop.

The blink drops to a steady red, which still reads as overtime. The
flash holds one dim frame rather than strobing, so the cue survives
without the strobe. Transitions and the button press effect go too.

Color carries every signal on its own, so nothing here loses meaning
when the motion stops.
Add /<room>/agenda for the people who need the running order rather
than the clock. It lists every cue with a projected start and end time.
It marks the cue that is on now and dims the ones already finished.

Backstage and the speakers both ask the same question, which is when
they are on. Ontime answers it with role specific views, and this is the
smallest version of that. The page needs no token, because a green room
screen cannot type one.

Clock times follow the running cue rather than a fixed plan. A session
that overruns moves every later cue with it, which is the number a
speaker actually wants. A cue that has run out chains the rest from now,
because nothing can start in the past.

The projection is a pure function with its own tests. Clock arithmetic
across midnight is easy to get wrong and hard to notice.

Verified against a live server. A running rundown showed the active cue
in the header and the following cues at their projected times. The
finished cue read as done.
A running order usually starts life in a spreadsheet. Add CSV and JSON
export, and an import that replaces the cue list from either.

The CSV reader accepts a header in any column order. It maps the
spellings a spreadsheet is likely to carry. So cue, presenter and length
all land in the right place. A duration takes minutes, mm:ss or
hh:mm:ss. An empty one falls back to five minutes.

Quoting follows the usual rules, because a cue title holding a comma is
normal. A doubled quote reads as one quote. Export quotes only the
fields that need it, so a diff of two exports stays readable.

An import either replaces the whole list or changes nothing. A bad
duration or a missing title returns the line number. The room keeps the
order it had. That matters when the file came from someone else minutes
before doors.

set_cues assigns fresh ids rather than reusing the old ones. A console
holding the previous list cannot then load a cue that moved.

Export stays open like the viewer. Import is a write, so it wants the
token.

Verified against a live server. A spreadsheet style CSV imported and
exported byte for byte. It then imported again into a second room.
Let the clock start a cue. An operator arms a wall clock time and the
timer waits at the top. The autopilot starts it when the time arrives.

Doors open at a time, not on a press. A room set up half an hour early
should run itself at the appointed minute. Nobody has to stand at the
laptop for it.

The armed time lives on the timer, so a snapshot keeps it across a
restart. Arming resets the run, since a cue that waits for the clock
should start from its full duration.

Starting by hand cancels the pending start. So do reset and loading a
cue. Without that rule an armed time fires again later and restarts a
talk that already ran.

The autopilot already scans for a cue that reached zero, so arming needs
no new machinery. A time already past fires on the next pass rather than
failing. That is what an operator means by a time they typed late.

The console takes hh:mm on a 24 hour clock. It picks the next
occurrence, so a late evening arming lands tomorrow rather than in the
past. That rule is a pure function with tests, because an off by one day
here would strand a show.

The viewer counts down to the start, so a stage display shows the wait.

Verified against a live server. An armed room started within a fifth of
a second of its time and cleared the armed value.
A room saved before a field existed failed to load. load_all skips a
file it cannot read. An operator restarting after an upgrade lost the
room without being told.

Every field of the saved state now carries a serde default. A missing
field takes its default rather than failing the whole file. A snapshot
from before presets existed comes back with the standard presets rather
than an empty row.

Found by the test that ships with this change. It loads a snapshot
written by hand in the older shape. That is the only way to catch this
before a user does.
Add an auxiliary timer. It carries a label and runs on its own clock.
The viewer shows it under the main readout.

A break needs its own countdown while the session timer keeps running.
Ontime carries an aux timer for the same reason. Until now an operator
had to choose which of the two to show.

The aux timer reuses the main Timer type rather than a parallel one. The
readout math, the overtime rule and the browser side all come free. The
two timers cannot drift apart in how they behave.

Its thresholds start at zero, so a break does not turn amber on its way
down. An operator who wants that can set them.

Both directions carry tests. Aux commands never move the main timer.
Main commands never move the aux timer. That is the mistake this shape
invites.

The viewer hides the aux timer until an operator shows it, and ?aux=0
hides it on one screen.

Verified against a live server. Both timers counted down together from
different start times, three seconds apart.
Let a command arrive as query parameters, so a controller that can only
issue a GET reaches every operator action.

Some hardware and some Companion buttons cannot send a body. The method
alone kept those setups out of the API.

Values convert by name rather than by shape. A field that holds text
stays text, so a message reading 5 does not arrive as a number. Any
other field takes a number when it parses as one, then true or false,
then text.

The endpoint shares the command type and the auth check with the POST
form. There is one place where a command turns into state.

Verified against a live server. Start, set_duration, message, flash and
blackout all landed over GET, and an unknown name returned 400.
Message presets and reduced motion support both landed, and the backlog
still showed them open. The edits meant to tick them matched nothing and
passed silently.

The list drives what gets built next. A wrong box there sends the next
iteration at work that is already done.
Add two ways to put a room back to nothing. clear_room resets every
part of the state. DELETE drops the room and its snapshot too.

A room accumulates a title, a rundown and a message from the last event
in it. The next event then starts with someone else's setup on screen. A
shared server also collects rooms nobody uses again.

Clearing keeps rev climbing rather than resetting it. A client compares
revisions. A counter that jumps backwards would read as a stale frame,
and the screen would keep the old show.

Delete clears the room before dropping it. A screen still connected then
sees an empty room, not the last state of a room that has gone.

Delete is idempotent. Removing a room that is not there answers
removed: false rather than failing, which suits a script.

Both want the token, since both throw work away. The console carries the
clear button behind a confirmation, and the picker offers delete per
room.

Verified against a live server. Clearing reset a room with a rundown and
a snapshot. Deleting removed both the room and its file.
Put the speaker beside the title and the cue note under the next up
line. Both come from the loaded cue, so a confidence monitor carries
what the rundown already holds.

The viewer reads them from the rundown rather than from a copy on the
display state. Editing a cue therefore changes the screen at once, and
there is one place where a cue title lives.

The speaker starts visible. The note starts hidden, because a note often
addresses the crew. Nobody wants switch to camera two on the stage
screen.

Both take a per screen override, so a booth monitor can carry the note
while the stage feed does not.

Verified against a live server. A loaded cue put the speaker on screen,
enabling notes revealed the note, and hiding the speaker cleared it.
Document the two new display fields and their per screen overrides. Say
which one starts visible.

The code landed in the previous commit without its documentation. The
edit meant to carry both died before it ran.
Announce the server as _scm._tcp.local. A phone or a laptop on the same
network then finds it without anyone reading an IP address aloud.

Reading an address off a laptop screen to a room is the worst part of
setting this up. The spec left this as an open question, and the core
works now.

--name sets the name a browser shows. It defaults to the port, so two
servers on one network stay apart. --no-mdns turns the announcement off.
A network that blocks multicast logs a warning and keeps serving.
Discovery is a convenience, not a dependency.

The advertisement lives for the life of the process. Dropping it
withdraws the service, so a server that stops does not linger in a
browser list.

Verified against a live server. dns-sd found Main Stage on two
interfaces. That is an independent tool, not the crate this code uses.
Give a toggle its own look. Blue now means the button does
something. Teal means the button is on.

Every lit button was the same blue. Clock, Progress and Speaker read
like Start and Show. An operator could not tell a state from an action
at a glance. That is the wrong thing to be unsure about during a talk.

A check mark carries the on state as well as the color. The meaning
then survives a colorblind operator and a badly calibrated projector.

Blackout keeps red, since it is a state nobody wants by accident. A
tone button shows its own color, so the selected tone matches what the
viewer displays. The show button stays blue and gains a ring, because
it both acts and reports.

Verified in a browser against a room with every toggle set.
Lay the console out in columns rather than a grid. Give every button row
an explicit column count.

A grid row is as tall as its tallest panel. A short panel therefore held
open dead space beside a tall one. On a wide screen a third of the
console was empty. Column flow packs each panel under the one above it.

The nudge row was the worst of it. Four buttons at a flexible width put
plus one minute on a line of its own. That moved a button an operator
reaches for under pressure. Four equal columns cannot do that.

The timer chips, the tone buttons, the toggles and the presets all get
the same treatment. The rundown still spans the full width, since a cue
list reads badly in a narrow column.

Verified in a browser at 1400 and at 420 pixels. Nothing wraps oddly at
either width, and the phone width falls to one column.
Add scale, title and next to the per screen overrides. A booth monitor
can now differ from the stage feed in size and wording. Until now it
could only differ in which parts it showed.

Two screens rarely want the same thing. A monitor two metres from a
speaker needs larger digits than a projector at the back. A booth screen
wants its own label, and often no next up line at all.

scale takes a percent. It clamps to the same 50 and 200 the room does.
One URL cannot ask for something the console cannot undo.

title replaces the text rather than hiding it. An empty title blanks the
line. A title of zero stays the text zero, which a flag could not
express.

Parsing moved into one tested function. The viewer had grown an inline
reader that no test covered, and each new override made that worse.

Verified against a live server. One screen showed a replaced title at
140 percent with no next line. The plain URL showed the room values.
Add a preset editor. Each row carries the text, a tone and a remove
button, with add, save and cancel below.

set_presets already existed, but only over the API. An operator setting
up a room had to reach for curl. That is the wrong tool for changing the
buttons they press most, minutes before doors.

The editor keeps its own copy while open. An arriving frame therefore
cannot overwrite half typed text. That is the same rule the other text
fields follow. Cancel discards, and nothing reaches the room until save.

A new row starts with placeholder text rather than empty. Save drops an
empty row, so an operator who adds one would watch it vanish.

Verified against a live server. Adding, removing and changing a tone all
survived a save, and cancel left the room untouched.
Add a soak test that reports how far a client's estimate of the server
clock strays. Add unit tests for the estimator itself.

The whole sync design rests on that estimate. A viewer renders between
state frames from its own clock plus an offset. An offset that drifts
therefore shows up as a timer that lies. Nothing measured it until now.

make soak runs 40 samples over ten seconds against a live server. It
prints mean, spread and drift. On loopback the offset holds inside two
milliseconds, a fraction of a display frame. It stays out of the normal
suite, since ten seconds is too long and the numbers depend on the
network.

The estimator moved into two pure functions, so a test can feed it
jitter. One stalled response used to be able to drag the clock. The
median covers that now, with a test to prove it.

A venue network is still the number that matters. The README says to run
the soak there before a show.

One test expectation was wrong on the first pass. The median of an even
count takes the lower middle. That is what the code did, and what the
test name said.
Turn the announcement off by default. --mdns turns it on, and --no-mdns
is gone.

A process that multicasts unasked surprises people. On a managed laptop
it also reads as beaconing, and endpoint security flags the binary.
Discovery is a convenience for a venue, so a venue can ask for it.

The name flag now only matters with --mdns.

Verified on the default path. The server logs no announcement, holds no
socket on port 5353, and listens only where --bind says. The advertise
path keeps its own test.
Record that the queue is empty, and what the final verification covered.
A file of ticked boxes says nothing about whether the thing runs.

The release build passed a live check of the auth gates and the CSV
round trip. It also covered snapshot restore across a restart and room
deletion. The soak measured 0.11ms mean offset and 1.5ms spread on
loopback.
Move the reference material into docs/ and cut the README to what a
first-time reader needs. Drop SPEC.md and BACKLOG.md.

The README had grown to 254 lines. It carried the whole command table,
every flag, the CSV rules and the per-screen overrides. The two
sentences saying what the thing is sat above a reference manual.

The split under docs/:

- operations.md: the API, the flags, and troubleshooting
- architecture.md: the parts as built, and the decisions worth knowing
- development.md: the layout, the suites, and the two rules that keep
 the frontend honest
- release.md: cutting a release

SPEC.md proposed the thing before it existed. Every part of it that
still matters now lives in docs/architecture.md. A design doc that
outlives its design drifts, and the log records the milestones it
tracked.

BACKLOG.md is an empty queue. The list did its job, and git log records
what shipped.

Also stop tracking .DS_Store, and ignore it along with .claude/.
Add the CI and release workflows, the Docker image, and a check that
keeps the two build matrices in step. Modelled on the AmberDAV setup,
without the framebuffer and SDL variants this project has no use for.

CI runs on every pull request and every push to main. It covers
formatting, clippy with warnings denied, and the Rust tests. It also
runs a syntax check over each frontend script and the JavaScript
suites. It then builds all six release targets. A cross-compile break
lands on the pull request rather than at release time.

Publishing a release builds each asset and attaches it. It then
generates SHA256SUMS from the uploaded assets. Last it pushes a
multi-architecture image to GHCR.

The tag is the version. The build stamps it into Cargo.toml and
Cargo.lock before compiling, so the binary reports the release. Without
that it would report whatever the manifest last carried.

The stamp step guards an empty tag and a tag that is not a plain
version. A release event can fire before GitHub's state settles and
carry no tag name. Without a guard that surfaces three steps later as
an opaque cargo error.

Both Linux assets link statically against musl, cross-linked with Zig.
Neither carries a libc dependency. macOS and Windows build on their own
runners, each cross-compiling the second architecture.

The matrix appears in both workflows and in docs/release.md. A check
compares the two matrices and fails when they drift. Verified in both
directions.

The image is Alpine holding the static binary. It runs as a non-root
user, serves on 8080, and keeps snapshots in /data. It carries tzdata,
which the wall clock and the local midnight calculation need.

Verified locally. Both workflows parse and the matrix check agrees on
six targets. The stamp step produces a Cargo pair that satisfies
--locked. The image builds clean and runs as scm with a writable /data.
Rename tests/aux.rs to tests/aux_timer.rs. AUX is a reserved device name
on Windows, and a file cannot carry it with any extension.

Both Windows jobs failed in actions/checkout, before the toolchain even
installed. The name arrived with the auxiliary timer. Nothing noticed
until CI ran on a Windows runner for the first time.

The new name also says what the file covers.
Add a screenshot of the stage display, the console and the agenda. The
files live under docs/images, which keeps the root directory clean.

A timer is a visual thing. Anyone deciding whether this fits their event
wants to see the screen a speaker looks at. No amount of prose does that
job.

Also move the tap-to-enable-sound hint to the top of the viewer. It sat
on top of the message overlay. The two fought over the same corner while
the chime was on and audio stayed locked.
Turn the duration branch into a match guard. Clippy on CI flagged the
nested if inside the arm.

Local clippy was 1.94 and CI installs the latest stable, which is 1.97.1
today. Clippy gains lints with each release, so a clean local run proved
nothing about CI.

The guard also reads better. An empty duration now falls through to the
arm that ignores a column, which is what the old branch did.

Verified against CI's own toolchain. Clippy, fmt and the 244 tests pass
on 1.97.1, and on 1.94 as well. The development guide now says to check
that way.
Editing the cue on air no longer stops the timer. A cue edit refreshes
the title, the next-up line and the target duration. It leaves the
transport alone.

update_cue called load_cue whenever the edited cue was the active one.
That picked up a new title. It also reset the run and zeroed the elapsed
time, which is right when an operator loads a cue. It is wrong when
someone fixes a typo. A script correcting the notes on the running cue
stopped the stage timer and threw the speaker's elapsed time away.

Loading a cue still starts from zero. The two behaviours now live in
separate functions, and three tests hold the line between them.

Found by a review of this branch.
The CSV reader now walks the whole document rather than one line at a
time. A quoted field can hold a newline, which is what a spreadsheet
writes for a note of two lines.

Export already quoted an embedded newline. Import split on line breaks
first, so the halves became separate rows. A cue with a two-line note
came back as two cues. One of them was a phantom, titled with the second
half of the note. The import returned 200, because both halves parsed.
The documented round trip through a spreadsheet destroyed data and said
nothing.

An error still reports a line number. It now points at the line the row
starts on, rather than counting rows.

Found by a review of this branch.
A restart now stops both timers. The main timer already came back paused
at its saved elapsed time, and the auxiliary timer did not.

An aux timer that was running came back running, still anchored to the
instant before the crash. Its readout then included the whole outage,
and it kept counting with nobody driving it. The documented rule covers
any timer that was running.

Found by a review of this branch.
Delete now retires the room. Its sockets close, and it refuses further
commands.

Delete dropped the registry entry and left the sockets running. A client
still held an Arc to the room. It kept sending commands, and its
subscribers kept seeing the results. Any new request created a fresh
room under the same name.

One console then drove a room that no reconnecting stage display could
reach, while HTTP reported the empty replacement. An operator who
deletes the wrong room now sees the console drop. It reconnects to an
empty room, which is what the delete meant.

Found by a review of this branch.
A read now validates the room name without bringing a room into being.
Only a write or a socket creates one.

Every GET went through a helper that created the room as a side effect.
A typo in a viewer URL left a name in the registry and in the picker. So
did a crawler walking the server. Deleting those wanted the token, while
creating them wanted nothing.

Reading a room that is not there now answers with the defaults. That is
what a viewer of an idle room needs anyway.

The GET command endpoint also converted a value by shape rather than by
name. A speaker called 1234 came back as 400 with a serde type error. So
did a note reading 0, and a label of true. Three more field names now
stay text: speaker, notes and on_expire.

The agenda repainted on a change of id, state, start or title only.
Editing the last cue's length, or any speaker, left the table stale
until something else moved.

Found by a review of this branch.
Four fixes to the pipeline that a review of this branch turned up.

The pull request image carried sha-${github.sha}. On a pull_request
event that is the merge commit. cleanup-pr.yml deletes sha-${head.sha}
instead. The two never matched. Every closed pull request therefore left
an orphaned image behind, and delete-untagged does not reach a tagged
manifest. The build now tags the head commit, which is the one cleanup
looks for.

The release tag guard accepted digits and dots only, so v1.0.0-rc1
failed the whole already-published release. It now takes a semver
pre-release or build suffix, and still rejects v1.2 and a word.

CI had no top-level permissions block, so every job ran with the default
token scope. It now reads contents, and the docker job still asks for
packages.

The image put the port and the state directory in CMD. Docker replaces
CMD with any argument after the image name. The documented run command
passes --token, so it silently dropped --state-dir and wrote rooms
nowhere. Both now sit in ENTRYPOINT, where a user argument is added
rather than substituted. The README and the release guide said the
opposite, and now match.
Auto advance now loads the next cue and starts it in one step, and
publishes one frame.

It took the lock twice, once to load and once to start. An operator
command landing between the two acted on a half-advanced room. A client
could also see the next cue stopped at its full duration. That frame
arrived a fifth of a second before the start.

The window was small, and the two-frame flicker was not. Both are gone.

Found by a review of this branch.
Two rules moved, so the guides move with them.

A read no longer creates a room. A command or a connected socket does,
and reading a room that is not there answers with the defaults.

A delete now closes the sockets on that room. A console watching it
drops and reconnects to an empty room.
rustfmt splits the assert onto its own lines.

A fmt run landed after the commit. The change sat in the working tree,
so CI checked the unformatted version and failed.
One file to paste into a Portainer stack, or to run with docker compose.
It pulls the published image, keeps rooms in a named volume, and health
checks the server.

SCM_TOKEN is required, and the stack refuses to deploy without it. A
server with an open console is not what anyone wants on a network.
SCM_TAG, SCM_PORT and TZ all have defaults, and the tag defaults to the
pull request image.

The token arrives through the environment rather than an argument, so it
stays out of the process list.

Verified against the published image. The stack came up healthy. The
console answered 401 without the token and 200 with it. The snapshot
landed in the volume, and a room survived a restart of the stack.
An unclosed quote is now an error naming the line it opened on.

The parser tracked quote state across the whole document and never
checked it at the end. One stray quote therefore turned every remaining
row into part of one field. A running order of four cues imported as one
cue. The rest of the file sat inside its title, and the import returned
200.

The bug it replaced damaged a single line. This one ate the remainder of
the file, which is worse. Three tests cover the stray quote, the quote
left open at the end, and the quote that closes properly.

Found by a second review pass over the fix.
Move the agenda repaint signature into shared.js and cover it.

Widening the signature was the one fix in the review round with no test
behind it. It sat inside a draw function, so nothing could reach it. A
later edit could drop a field and no suite would notice.

Nine cases now assert that a change to any field the table shows
produces a new signature. One more holds an unchanged table to its own
signature.

Found by a second review pass over the fix.
Two narrow fixes to the release workflow.

The tag guard allowed one suffix, so 1.0.0-rc.1+build.5 failed while
1.0.0-rc1 passed. It now takes a pre-release and build metadata
together, and still rejects v1.2 and a word.

The docker job tagged latest on every release. Now that a pre-release
can reach it, cutting v1.1.0-rc1 would have moved latest onto a release
candidate. A pre-release tag now publishes its own version tag only.

Found by a second review pass over the fix.
The port and the state directory move from the image ENTRYPOINT to
environment variables. Every flag now reads one, so a stack can
configure the server either way.

Putting them in ENTRYPOINT stopped an argument from replacing them,
which was the point. It also made an override fatal. A flag
given twice is an error, so docker run with --port died at startup. The
documentation invited exactly that. The previous shape dropped the
defaults instead. Neither was right.

An environment default loses to a command line argument, so both work
now.

Verified in the real image on three runs. No arguments keeps 8080 and
/data. A --port 9000 serves on 9000 rather than crashing. A --state-dir
moves the snapshot, and --token still gates the console.

Found by a second review pass over the fix.
The stack variable becomes SCM_HOST_PORT. It maps a port on the host,
and it never reaches the container.

SCM_PORT means the port the server listens on, and the image sets it.
The same name on the left side of a port mapping gave one name two
meanings. That belongs least in a file someone pastes into Portainer and
deploys.

The release guide now says which is which.

Verified with the published image. The stack came up healthy on the host
port. The console answered 401 without the token and 200 with it.
The tag guard drops the plus suffix it accepted last round.

Build metadata is valid semver, and it cannot be a Docker tag. A release
tagged v1.0.0+build would have published binaries and checksums first.
The image would then fail, leaving a half-finished release.

Refusing it in the first step of the first job means nothing publishes.
The error says why.

A pre-release tag still works, since a hyphen is legal in both.
The picker showed its link panel on load, holding a broken image and two
empty fields.

A panel carries display: grid, and the browser hides a [hidden] element
with a rule of equal weight. The class won on source order, so the
attribute did nothing. The panel therefore appeared before any room name
existed. The QR image had no source, and rendered as a broken icon.

A single [hidden] rule with display none now sits at the top of each
stylesheet. Every other hidden element already carried its own rule, and
this was the one that did not. The global rule means the next one cannot
repeat it.

Verified in a browser. The panel stays hidden on load. A room name
brings it back with a scannable code and both links.

Reported from a live deployment. I had seen the same broken image in my
own screenshot earlier and blamed the browser harness for it.
A browser opening the console without the token now gets a form.
Entering it lands on the console and stores the cookie. Every other room
on the server then opens without asking again.

The refusal was a dead end. It said "this room needs the operator token"
as plain text on a black page, with nowhere to put one. The only way in
was to know that ?token= works and to type it into the address bar by
hand. Clicking a console link from the picker hit that wall, which is
where anyone starts.

A wrong token brings the form back saying so. It also takes the bad
value out of the address bar, so a refresh cannot resubmit it.

An API caller still gets the plain refusal, since a form is no use to
curl. Five tests cover the pair: HTML with a form for the console, text
for POST, GET and DELETE.

Reported from a live deployment with volunteers on it.
An edit to the rundown now refreshes the title and the next-up line.
Deleting the loaded cue clears both.

Those two lines come from the loaded cue, and only a load ever wrote
them. Deleting every cue therefore left the last title and next-up
frozen on the stage display. The timer below them had no rundown behind
it. An operator clearing the list for a plain timer could only fix it by
typing over both fields.

The same staleness covered every list edit. Deleting the cue that was
next left the old name on screen. So did renaming it, reordering the
list, or adding a cue after the last one.

A loaded cue now owns the screen, so the lines follow the list. Without
one the fields belong to whoever typed them, and a rundown edit leaves
them alone. Eight tests cover both halves.

Reported from a live deployment.
Two things a first-time operator reached for and did not find. Both land
in the same console files, so they arrive together.

Next and go loads the following cue and starts it, on a button and on
the g key. A plain load still leaves the timer stopped. That is the
convention every product in the survey follows. An operator lines up the
next talk while the current one runs. A load that started the clock
would wreck the running cue.

Auto advance already loaded and started in one step, though. A human
pressing Next got only half of that. The inconsistency was mine, not the
convention's.

The command applies both halves under one lock, so it lands in one
revision and one frame. At the end of the list it does nothing, matching
auto advance, which leaves the last cue running into overtime.

Edit opens a cue in place. The form carries its title, speaker, length
and note. update_cue has existed since the rundown shipped, and only the
API could reach it. The row holds its own copy while open, so an
arriving frame cannot overwrite half typed text, and Cancel discards.

Six tests cover the walk, the empty rundown, the first cue with nothing
loaded, and the no-op at the end. Both paths verified in a browser, on
the button, on the key, and through the form.
A JSON document now carries duration in the shape the CSV uses. Every
command that sets a length accepts it too.

The two exports disagreed. A CSV said 5:30 while the JSON said 330000,
and the JSON carried an id the CSV left out. Nobody could edit the pair
interchangeably. The JSON document now writes title, speaker, duration
and notes, and nothing else. Either file rebuilds the same running
order.

An import reads whichever spelling arrives. So do add_cue, update_cue,
set_duration and aux_set_duration. A clock value takes 7:30 or 1:02:03.
A bare number under a thousand is minutes, since nobody sets a talk to
half a second. A thousand or more stays milliseconds, so duration_ms
keeps working and an existing script keeps running.

Live state still reports duration_ms as a number. The browser does
arithmetic on it every frame, and parsing a clock string there would buy
nothing.

adjust keeps its milliseconds. A delta is not a duration, and -30000
there means half a minute off.

The add cue form hinted 10:00 while a blank field gave five minutes. The
hint now says 5:00, which is what a blank field does.
set_thresholds now takes 3:00, a bare 3 for minutes, or milliseconds. It
reads warn and danger as spellings of warn_ms and danger_ms.

A threshold is a length. Leaving it out of the previous commit meant
warn_ms: 3 still asked for three milliseconds. That is the same nonsense
the duration rule exists to stop.

Zero still turns a threshold off. The rule only converts a number above
zero, so nothing about disabling one changes.

schedule_start keeps its milliseconds. It carries a clock reading rather
than a length, and an epoch value is far above the boundary anyway.
A bind mount arrives with the host ownership. The user the release image
runs as often cannot write to /data. Every snapshot then failed with one
warning per save, long after the operator stopped reading the log. The
rooms looked persistent and the directory stayed empty.

Opening the store now writes and removes a probe file, and a failure is
fatal. The message names the directory and both fixes. Chown it to the
user the container runs as, or set `user:` to match the directory.

The docs lead with the `user:` override. Chowning a host directory to
the image uid 100 hands it to a system account. That account means
something else on a real distro. Docker's own mechanism needs no
privileges here, and the server writes nothing outside the state
directory, so any uid works.

The store also opens once now instead of twice.
@KyleJamesWalker
KyleJamesWalker merged commit 283b25d into main Aug 20, 2026
9 checks passed
@KyleJamesWalker
KyleJamesWalker deleted the initial-release branch August 20, 2026 19:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant