refactor(teleprompter): drop code that was not earning its keep

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
This commit is contained in:
Claude
2026-08-13 16:42:07 +00:00
parent 15dac35748
commit 323b30ea68
9 changed files with 140 additions and 167 deletions
@@ -10,7 +10,7 @@ import {
hasArrived,
linesPerMinuteToPxPerSecond,
} from './teleprompter.scroll';
import type { TeleprompterController } from './teleprompter.types';
import type { ScriptBlock, TeleprompterController } from './teleprompter.types';
/** how much of a screen a page jump moves */
const PAGE_FRACTION = 0.85;
@@ -25,8 +25,8 @@ interface UseTeleprompterScrollArgs {
selectedEventId: string | null;
/** percentage from the top of the screen */
readingLinePos: number;
/** changes whenever the rendered document changes, forcing a remeasure */
contentKey: string;
/** the rendered document, watched so a follow can retarget once blocks remount */
blocks: ScriptBlock[];
}
/**
@@ -49,7 +49,7 @@ export function useTeleprompterScroll({
followLoaded,
selectedEventId,
readingLinePos,
contentKey,
blocks,
}: UseTeleprompterScrollArgs) {
const scrollerRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
@@ -67,47 +67,19 @@ export function useTeleprompterScroll({
const catchUpTargetRef = useRef<number | null>(null);
const pendingDeltaRef = useRef(0);
const adoptScrollRef = useRef(false);
const tickRef = useRef<(ts: number) => void>(() => {});
const [isRunning, setIsRunning] = useState(false);
const [speed, setSpeed] = useState(initialSpeed);
const [followLocked, setFollowLocked] = useState(false);
const [atEnd, setAtEnd] = useState(false);
/** wakes the animation frame loop if it is not already running */
const ensureLoop = useCallback(() => {
if (frameRef.current !== null) return;
lastTsRef.current = performance.now();
frameRef.current = requestAnimationFrame((ts) => tickRef.current(ts));
}, []);
/** recomputes the scroll bounds and the line height the speed is derived from */
const measure = useCallback(() => {
const scroller = scrollerRef.current;
const content = contentRef.current;
if (!scroller || !content) return;
maxScrollRef.current = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
const sample = content.querySelector('[data-prompter-body]');
if (sample) {
const computed = getComputedStyle(sample);
const parsedLineHeight = Number.parseFloat(computed.lineHeight);
if (Number.isFinite(parsedLineHeight) && parsedLineHeight > 0) {
lineHeightRef.current = parsedLineHeight;
} else {
// line-height resolves to the string 'normal' when it is not set explicitly
const parsedFontSize = Number.parseFloat(computed.fontSize);
if (Number.isFinite(parsedFontSize)) {
lineHeightRef.current = parsedFontSize * 1.5;
}
}
}
speedPxSecRef.current = linesPerMinuteToPxPerSecond(speedRef.current, lineHeightRef.current);
}, []);
tickRef.current = (ts: number) => {
/**
* Advances the scroll by one frame.
*
* Reads and writes only refs, so it never needs rebuilding: keeping it stable
* is what lets the loop reschedule itself without going through the render.
*/
const tick = useCallback((timestamp: number) => {
const el = scrollerRef.current;
if (!el) {
frameRef.current = null;
@@ -123,8 +95,8 @@ export function useTeleprompterScroll({
adoptScrollRef.current = false;
}
const deltaSeconds = frameDeltaSeconds(ts - lastTsRef.current);
lastTsRef.current = ts;
const deltaSeconds = frameDeltaSeconds(timestamp - lastTsRef.current);
lastTsRef.current = timestamp;
let next = posRef.current;
@@ -161,10 +133,42 @@ export function useTeleprompterScroll({
catchUpTargetRef.current !== null ||
pendingDeltaRef.current !== 0 ||
adoptScrollRef.current;
frameRef.current = hasWork ? requestAnimationFrame((next) => tickRef.current(next)) : null;
};
frameRef.current = hasWork ? requestAnimationFrame(tick) : null;
}, []);
/** wakes the animation frame loop if it is not already running */
const ensureLoop = useCallback(() => {
if (frameRef.current !== null) return;
lastTsRef.current = performance.now();
frameRef.current = requestAnimationFrame(tick);
}, [tick]);
/** recomputes the scroll bounds and the line height the speed is derived from */
const measure = useCallback(() => {
const scroller = scrollerRef.current;
const content = contentRef.current;
if (!scroller || !content) return;
maxScrollRef.current = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
const sample = content.querySelector('[data-prompter-body]');
if (sample) {
const computed = getComputedStyle(sample);
const parsedLineHeight = Number.parseFloat(computed.lineHeight);
if (Number.isFinite(parsedLineHeight) && parsedLineHeight > 0) {
lineHeightRef.current = parsedLineHeight;
} else {
// line-height resolves to the string 'normal' when it is not set explicitly
const parsedFontSize = Number.parseFloat(computed.fontSize);
if (Number.isFinite(parsedFontSize)) {
lineHeightRef.current = parsedFontSize * 1.5;
}
}
}
speedPxSecRef.current = linesPerMinuteToPxPerSecond(speedRef.current, lineHeightRef.current);
}, []);
// stop the loop when the view goes away
useEffect(() => {
return () => {
if (frameRef.current !== null) {
@@ -185,7 +189,8 @@ export function useTeleprompterScroll({
setSpeed(clampSpeed(initialSpeed));
}, [initialSpeed]);
// remeasure whenever the document or the viewport changes shape
// the observer covers every reflow that matters, script edits included, so
// nothing else needs to ask for a remeasure
useEffect(() => {
measure();
@@ -197,7 +202,7 @@ export function useTeleprompterScroll({
observer.observe(scroller);
observer.observe(content);
return () => observer.disconnect();
}, [measure, contentKey]);
}, [measure]);
// webfonts land after first paint and reflow the whole document
useEffect(() => {
@@ -245,7 +250,7 @@ export function useTeleprompterScroll({
catchUpTargetRef.current = clamp(top, 0, Math.max(maxScrollRef.current, 0));
setAtEnd(false);
ensureLoop();
}, [selectedEventId, followLoaded, followLocked, readingLinePos, contentKey, ensureLoop]);
}, [selectedEventId, followLoaded, followLocked, readingLinePos, blocks, ensureLoop]);
const lockFollow = useMemo(() => throttle(() => setFollowLocked(true), FOLLOW_LOCK_THROTTLE), []);
@@ -262,18 +267,15 @@ export function useTeleprompterScroll({
}
}, [ensureLoop, followLoaded, lockFollow]);
const registerBlock = useCallback(
(id: string) => (element: HTMLElement | null) => {
// entry ids are not guaranteed to be valid CSS selectors, so we keep a map
// rather than reaching for querySelector
if (element) {
blockRefs.current.set(id, element);
} else {
blockRefs.current.delete(id);
}
},
[],
);
// entry ids are not guaranteed to be valid CSS selectors, so the follow target
// is looked up through this map rather than with querySelector
const registerBlock = useCallback((id: string, element: HTMLElement | null) => {
if (element) {
blockRefs.current.set(id, element);
} else {
blockRefs.current.delete(id);
}
}, []);
const controller: TeleprompterController = useMemo(() => {
const play = () => {
@@ -293,8 +295,6 @@ export function useTeleprompterScroll({
};
return {
play,
pause,
togglePlay: () => (runningRef.current ? pause() : play()),
nudge: (lines: number) => {
pendingDeltaRef.current += lines * lineHeightRef.current;