A maintenance pass over the view, aimed at the places where a later change
would go wrong quietly rather than fail.
Every option default existed three times: the declaration the params editor
renders, the fallback the parser applies, and a fallback in the stylesheet that
nothing could reach because the view always sets the variable. Editing one left
the editor showing a value the view was not using. Defaults and bounds now live
in one object which both the declaration and the parser read, and the dead
stylesheet copies are gone. A test asserts that parsing an empty query returns
exactly what the editor declares, so the two cannot drift apart again.
That test found the first case immediately: the script source declared a
default of none but parsed to null. It is now none in both, which also takes a
null out of the options type and a branch out of the view.
The speed step was a constant in the keymap and a literal in the overlay, so
the buttons would have kept stepping by two if the constant ever changed. Both
now read the same export.
The line height was measured by looking up an element by a data attribute set
in another file. Removing that attribute would not have failed, it would have
fallen back to a guess which happens to be correct at the default line height
and wrong at any other. The measurement now reads the element the hook already
holds, which is where the stylesheet sets the line height. Confirmed against
the browser: at double the line height the script travels 1.98 times as far.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
Three simplifications from a review of where the complexity actually sat.
The animation frame loop now runs for the lifetime of the view instead of being
woken around each action. Waking a sleeping loop meant that every path which
could move the script had to remember to do it, across eight call sites, and
forgetting simply lost the action with no error. An idle frame costs one pass
of arithmetic and the browser suspends the loop entirely while the tab is
hidden. This removes ensureLoop, the frame handle and the work test.
Running always did break something, which is worth recording: any scroll the
loop had not made itself was reverted on the next frame, so a scrollbar drag or
find in page would snap back. The loop now compares the scroller against where
it left it and adopts the DOM position when they differ. That covers every
cause rather than only the gestures which had a handler, so the adopt flag and
its wiring are gone too, and there is an end to end test for it.
Dropped hidePast in favour of dimPast. They read as alternatives but only one
is safe during a read: removing past events shortens the document underneath
the reader, while dimming is only paint. Also dropped the shade reading line
variant, which drew the same gradient over the same box as dimPast, so turning
both on stacked two of them.
The reading line position was carried as two CSS variables, a percentage and a
unitless number. That split caused the padding bug earlier. One unitless
variable now serves both, scaled by 1% for the overlays and 1dvh for the
padding.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
The flip options seeded useState and were then never read again, so a change
took effect only on a fresh load. Toggling flip in the view options did
nothing until the page was reloaded.
They now sync when the param changes, the way the speed option already does,
while the F key still owns the state between changes.
This also matters beyond the params editor. Ontime can redirect a specific
client to a path, and the teleprompter keeps its whole configuration in the
query string, so one device can already reconfigure a prompter running on
another. Verified that a same route query change applies live and leaves the
scroll position untouched, which is what makes that usable during a show.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
The client runs babel-plugin-react-compiler in annotation mode, so 'use memo'
is the opt in and the codebase already uses it in eighteen places. The
teleprompter was the odd one out, hand rolling what the compiler does better.
Teleprompter and ScriptBlock now carry the directive, which replaces two
useMemo calls, four useCallback calls and a memo wrapper. Verified against the
compiler rather than assumed: the two files emit 46 and 15 memo cache slots
respectively, finer grained than the six the hand written version had.
The scroll engine and the key handler deliberately keep no directive. They
derive nothing and render nothing, they drive an animation frame loop through
refs, so there is nothing to memoise and no reason to put them through the
compiler.
Also fixes a hazard the conversion surfaced. The follow effect listed the
blocks array in its dependencies, but memoisation is a performance hint React
is free to discard, under the compiler as much as by hand. A rebuilt but
otherwise identical list would re-fire the jump to the loaded event and yank
the script back mid read. It now watches whether the loaded event has a block
at all, which is the condition it actually cared about and does not depend on
identity.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
Replaces the view hotkey store with a suppressSpaceHotkey prop on
ViewNavigationMenu, alongside the suppressSettings prop which already exists
for exactly this purpose: a view opting out of a navigation chrome behaviour.
The store had one advantage, that it covered the preset route without the
router naming the view. That is a single expression in PresetView, and paying
for it with a global claim was the wrong trade: the claim was lifecycle state
that had to be released on unmount to avoid stranding Space for the whole
client, where a prop simply describes the route.
Verified both routes: /teleprompter and a locked preset targeting the
teleprompter leave Space to the view, while the timer and a preset targeting
the timer still open the navigation menu with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
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
The teleprompter was the only view ignoring the global Flip Screen option,
so it now composes with the per view flips.
The two turn out to be the same mechanism: the shared `.mirror` class is
rotate(180deg), which is the same matrix as scale(-1, -1), so Flip Screen
is exactly a flip on both axes at once. Folding it in with XOR means the
teleprompter matches every other view when Flip Screen is on, and there is
still only one transform on the element.
It cannot replace the per view flips. A rotation preserves handedness, so
it never produces the mirror image a beam splitter reflection needs, and it
cannot address one axis on its own. Those remain URL params, which also
lets a shared link to the talent screen carry the rig's setup.
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
* fix: issue where a count-to-end would lead to incorrect expected times
* fix: include add time in overtime when countToEnd
* fix: ui and server use same calculation for expected end
The Stage Timer and PiP Timer views relied on `align-content: center` to
vertically center the timer digits inside `.timer-container`, which is a
plain block element. Aligning children of a block container via
`align-content` only works in Chromium 123+ (Firefox 125+, Safari 17.4+).
Embedded browsers such as vMix Browser Input (CEF V115) and other older
CEF/Chromium-based production tools ignore the property, so the digits
fall back to the top of the container while every other element renders
correctly.
Make `.timer-container` a real flex column and center with
`justify-content: center`, which is universally supported and matches the
centering approach used elsewhere in the codebase. The removed
`justify-self`/`align-self` were no-ops on a full-width flex item.
Fixes#2126
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T6ENZ3r6JXZb1fpYw2oNY
Surface {{groupNow.*}} template variables (title, note, colour, times,
custom fields) in the automation template autocomplete so events inside a
group can reference their group. The runtime store already carries
groupNow, so substitution and filters worked already; this makes it
discoverable.
Extend the message-secondary action with an optional text field so an
automation can set the secondary message content, not just its source.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZYPZVdZLWowU7DyzGkWyy