Review pass over the view.
Removed: estimateWordsPerLine and linesPerMinuteToWordsPerMinute, written for
a words-per-minute readout that was never built; play and pause on the
controller, which nothing outside the hook called; and three constants that
were exported but never imported.
The tick function was being reassigned to a ref on every render, which is a
side effect during render. It only ever touched refs and state setters, so it
is now a stable callback and the ref is gone.
The synthetic contentKey string is replaced by the memoised blocks array it
was standing in for. It was also a dependency of the ResizeObserver effect,
which tore the observer down and rebuilt it for no gain: the observer already
covers every reflow that changes the document.
ScriptBlock was memoised but never actually memoising, because the parent
built its ref callback inline and handed it a new identity every render. That
also churned the follow map, unregistering and re-registering every block. The
id is now bound inside the block against a stable callback.
Tests: dropped six that asserted arithmetic identities or wrapped clamps
rather than behaviour, and added three for branches that were untested,
group titles across and back into a group, and a heading with no cue.
The e2e rewind assertion waited 600ms for an eased scroll that needs about
900ms from a nudge and a second from the bottom of a long script; it now
polls. The navigation menu assertion raced app hydration and now waits for
the view, as the existing navigation tests do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
Adds a teleprompter at /teleprompter which builds its script from the
rundown rather than from an uploaded file, so the read follows the show.
The script comes from a text custom field chosen per view, and the whole
rundown renders as one continuous document with a heading per segment.
That is how broadcast prompters work: the operator scrolls to the right
section as the show moves, so a hard cut on every event change would take
the tail of the line the talent is still reading. Following the loaded
event is a soft jump which releases when the user scrolls by hand, the
same interaction the operator view already uses.
Controls are local, and match the convention shared by prompter software:
space to run, arrows for speed and nudge, home to rewind, F to flip. That
convention doubles as the hardware protocol, since foot pedals and hand
controllers are USB HID devices emitting these keystrokes, so they work
with no setup. Space is claimed back from the navigation menu for the
lifetime of the view via a small store, since the router renders the menu
generically for presets and a prop would not reach it.
Scrolling uses native scrollTop on an overflow container, with the
animation frame loop as its only writer. Position is kept as a float in a
ref: at readable speeds the per frame movement is well under a pixel, so
rounding every frame would stall the scroll, and holding it in state would
re-render the document sixty times a second. Scroll anchoring and smooth
scroll behaviour are both disabled because each would be a second writer.
Notable details:
- flip applies to the view root, so a beam splitter inverts the scroll
direction along with the text
- content padding is derived from the viewport height, not a percentage,
which resolves against width and would strand the first line
- image custom fields are refused even when typed into the URL
- script text is rendered as a text node, never as markup
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf