fix(views): make boolean view params work, and retune the prompter

The params editor could not represent a boolean which defaults to true.
Two bugs stacked:

- ParamInput fell back with `isStringBoolean(param) ?? defaultValue`, but
  isStringBoolean answers false for an absent param rather than nothing, so
  the ?? never fired and every switch opened off whatever its option said.
- An unchecked checkbox is absent from the form data rather than present and
  false, so switching one off wrote no param and the parser fell back to the
  default the user was trying to leave.

Either one alone is invisible while every boolean defaults to false, which is
why this surfaced with the teleprompter. Together they made the whole panel
look inert: the switch showed off, the view showed on, and Apply did nothing.

Prompter changes from the review:

- Speed is calibrated against the reading rate rather than picked for feel.
  30 lines per minute was about 300 words per minute, roughly twice a
  broadcast read; the default is now 12, measured at 129 wpm on the default
  column. The ceiling comes down from 200 to 40 so the arrows stay useful.
- Smaller, denser defaults: 40px over 1.3 line height in an 80% column, which
  is 21 lines on a 1080p screen where the old defaults gave 11.
- The reading line is a marker one line tall in the gutter beside the text,
  replacing the rule across the words and the pair of margin arrows. The
  option is a boolean now that there is one style rather than three.
- Space always drives the transport. It was deferring to whichever control
  had focus, so a prompter stopped responding to the pedal after anyone
  touched a button; the overlay drops focus after a pointer press instead.
  Enter still activates a focused control, so the overlay stays keyboard
  operable.
- The help dialog is laid out as the rundown shortcuts panel is, down to the
  Kbd keycaps and the grouping, and no longer explains foot pedals.

Headings stay on the same left rail as the script rather than centred: they
are signposts for the operator, and a second alignment would give the eye
something new to find at every segment change.

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-18 12:00:25 +00:00
parent 8a31cc5018
commit 6589bcceee
17 changed files with 387 additions and 175 deletions
@@ -49,7 +49,20 @@ export default function ParamInput({ paramField }: ParamInputProps) {
} }
if (type === 'boolean') { if (type === 'boolean') {
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) ?? defaultValue} />; /**
* isStringBoolean answers false for an absent param rather than nothing, so
* it cannot be used to fall through to the default: a ?? here would never
* fire and every switch would open off, whatever the option declares. That
* only shows on options which default to true, where the editor would then
* disagree with the view it is meant to be editing.
*/
const paramValue = searchParams.get(id);
return (
<ControlledSwitch
id={id}
initialValue={paramValue === null ? Boolean(defaultValue) : isStringBoolean(paramValue)}
/>
);
} }
if (type === 'number') { if (type === 'number') {
@@ -227,12 +227,45 @@ describe('getURLSearchParamsFromObj()', () => {
], ],
}, },
]; ];
// a switch which is off sends nothing at all, it is not present and false
const params = { const params = {
bool1: 'off',
bool2: 'on', bool2: 'on',
}; };
const result = getURLSearchParamsFromObj(params, mockOptionsWithBooleans); const result = getURLSearchParamsFromObj(params, mockOptionsWithBooleans);
expect(result.get('bool1')).toBe('false'); expect(result.get('bool1')).toBe('false');
expect(result.get('bool2')).toBe('true'); expect(result.get('bool2')).toBe('true');
}); });
it('omits booleans which match their default', () => {
const mockOptionsWithBooleans: ViewOption[] = [
{
title: OptionTitle.StyleOverride,
options: [
{
id: 'onByDefault',
title: 'onByDefault',
description: 'On by default',
type: 'boolean',
defaultValue: true,
},
{
id: 'offByDefault',
title: 'offByDefault',
description: 'Off by default',
type: 'boolean',
defaultValue: false,
},
],
},
];
// both switches left as they were: the URL stays clean
const untouched = getURLSearchParamsFromObj({ onByDefault: 'on' }, mockOptionsWithBooleans);
expect(untouched.toString()).toBe('');
// both switches flipped: each one has to be written to survive a reload
const flipped = getURLSearchParamsFromObj({ offByDefault: 'on' }, mockOptionsWithBooleans);
expect(flipped.get('onByDefault')).toBe('false');
expect(flipped.get('offByDefault')).toBe('true');
});
}); });
@@ -155,6 +155,20 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
}); });
}); });
/**
* An unchecked checkbox is absent from the form data rather than present and
* false, so a boolean can only be read by looking for the ones which did not
* arrive. Without this an option defaulting to true could never be turned off:
* the switch would send nothing, no param would be written, and the parser
* would fall back to the default the user was trying to leave.
*/
metadata.booleanFields.forEach((id) => {
if (id in paramsObj) return;
if (metadata.defaultValues[id] !== 'false') {
addUniqueParam(id, 'false');
}
});
// Then process user-provided values // Then process user-provided values
Object.entries(paramsObj).forEach(([id, value]) => { Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length) { if (typeof value === 'string' && value.length) {
@@ -84,28 +84,34 @@
} }
.teleprompter__block { .teleprompter__block {
margin-bottom: 1.5em; margin-bottom: 1em;
&[data-loaded] .teleprompter__heading { &[data-loaded] .teleprompter__heading {
color: $accent-color; color: $accent-color;
} }
} }
/**
* Headings and group names are signposts for the operator, not lines to be read
* aloud, so they stay small and stay on the same left rail as the script. Given
* their own alignment they would become a second thing for the eye to find on
* every segment change, which is the opposite of what a prompter is for.
*/
.teleprompter__group { .teleprompter__group {
font-size: 0.4em; font-size: 0.34em;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
color: $viewer-label-color; color: $viewer-label-color;
margin-bottom: 0.5em; margin-bottom: 0.35em;
} }
.teleprompter__heading { .teleprompter__heading {
font-size: 0.45em; font-size: 0.38em;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: $viewer-secondary-color; color: $viewer-secondary-color;
margin-bottom: 0.4em; margin-bottom: 0.3em;
} }
.teleprompter__body { .teleprompter__body {
@@ -128,36 +134,33 @@
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0));
} }
/**
* The marker aligns to the text column rather than the viewport, so it tracks
* the text width option instead of drifting away from the words as the column
* narrows. The content padding puts the top of the read line exactly at the
* reading position, which is why this is not centred on it.
*/
.teleprompter__reading-line { .teleprompter__reading-line {
position: absolute; position: absolute;
top: calc(var(--tp-reading-line) * 1%);
left: 0; left: 0;
right: 0; right: 0;
top: calc(var(--tp-reading-line) * 1%); width: var(--tp-text-width);
margin-inline: auto;
height: calc(var(--tp-font-size) * var(--tp-line-height));
pointer-events: none; pointer-events: none;
&--line {
border-top: 2px solid rgba($accent-color, 0.6);
}
} }
.teleprompter__arrow { .teleprompter__reading-marker {
position: absolute; position: absolute;
top: -0.6em; top: 0;
width: 0; bottom: 0;
height: 0; /* sits in the gutter beside the column, never over the words */
border-top: 0.6em solid transparent; right: calc(100% + 0.4em);
border-bottom: 0.6em solid transparent; width: 0.16em;
font-size: clamp(16px, 2vw, 32px); border-radius: 0.08em;
background: $accent-color;
&--left { font-size: var(--tp-font-size);
left: 0;
border-left: 0.9em solid $accent-color;
}
&--right {
right: 0;
border-right: 0.9em solid $accent-color;
}
} }
.teleprompter__controls { .teleprompter__controls {
@@ -223,42 +226,78 @@
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
max-height: 80dvh; width: min(92vw, 32rem);
max-height: 85dvh;
overflow-y: auto; overflow-y: auto;
padding: clamp(16px, 2vw, 24px); padding: 1.5rem;
background: $viewer-background-color; background: $viewer-background-color;
color: $viewer-color; color: $viewer-color;
border-radius: $element-border-radius; border-radius: $element-border-radius;
font-size: $base-font-size; /* the card is chrome, not script: it keeps the UI scale, not the prompter's */
font-size: 1rem;
} }
.teleprompter__help-title { .teleprompter__help-title {
font-size: $title-font-size; font-size: 1.25rem;
margin-bottom: $view-element-gap; font-weight: 600;
margin-bottom: 1.25rem;
} }
.teleprompter__help-row { .teleprompter__help-groups {
display: flex; display: grid;
gap: clamp(16px, 2vw, 24px); gap: 1.25rem;
padding: 0.25em 0;
} }
.teleprompter__help-keys { .teleprompter__help-group-title {
flex: 0 0 10em; margin: 0 0 0.5rem;
color: $viewer-color; font-size: calc(1rem - 3px);
font-variant-numeric: tabular-nums; font-weight: 600;
} text-transform: uppercase;
.teleprompter__help-action {
color: $viewer-secondary-color;
}
.teleprompter__help-note {
margin-top: $view-element-gap;
color: $viewer-label-color; color: $viewer-label-color;
} }
.teleprompter__help-close { .teleprompter__help-list {
margin-top: $view-element-gap; display: grid;
gap: 0.375rem;
}
.teleprompter__help-row {
min-height: 1.625rem;
display: grid;
grid-template-columns: minmax(8rem, 1fr) minmax(0, auto);
align-items: center;
gap: 0.75rem;
font-size: calc(1rem - 3px);
}
.teleprompter__help-label {
min-width: 0;
line-height: 1.2;
color: $viewer-secondary-color;
}
.teleprompter__help-keys {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.25rem 0.5rem;
min-width: 0;
}
.teleprompter__help-combo {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.25rem;
}
.teleprompter__help-separator {
color: $viewer-label-color;
font-size: calc(1rem - 5px);
}
.teleprompter__help-close {
margin-top: 1.5rem;
} }
@@ -156,7 +156,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
</div> </div>
</div> </div>
<ReadingLine variant={options.readingLine} dimPast={options.dimPast} /> <ReadingLine showReadingLine={options.readingLine} dimPast={options.dimPast} />
<ControlOverlay <ControlOverlay
isRunning={isRunning} isRunning={isRunning}
@@ -1,5 +1,5 @@
import { resolveTeleprompterAction, type TeleprompterKeyEvent } from '../teleprompter.keymap'; import { resolveTeleprompterAction, type TeleprompterKeyEvent } from '../teleprompter.keymap';
import { FONT_SCALE_STEP } from '../teleprompter.scroll'; import { FONT_SCALE_STEP, SPEED_STEP, SPEED_STEP_COARSE } from '../teleprompter.scroll';
function makeEvent(overrides: Partial<TeleprompterKeyEvent>): TeleprompterKeyEvent { function makeEvent(overrides: Partial<TeleprompterKeyEvent>): TeleprompterKeyEvent {
return { return {
@@ -42,18 +42,18 @@ describe('resolveTeleprompterAction()', () => {
}); });
test('horizontal arrows change speed', () => { test('horizontal arrows change speed', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight' }))).toEqual({ type: 'speed', delta: 2 }); expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight' }))).toEqual({ type: 'speed', delta: SPEED_STEP });
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowLeft' }))).toEqual({ type: 'speed', delta: -2 }); expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowLeft' }))).toEqual({ type: 'speed', delta: -SPEED_STEP });
}); });
test('shift makes the speed step coarse', () => { test('shift makes the speed step coarse', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight', shiftKey: true }))).toEqual({ expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight', shiftKey: true }))).toEqual({
type: 'speed', type: 'speed',
delta: 10, delta: SPEED_STEP_COARSE,
}); });
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowLeft', shiftKey: true }))).toEqual({ expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowLeft', shiftKey: true }))).toEqual({
type: 'speed', type: 'speed',
delta: -10, delta: -SPEED_STEP_COARSE,
}); });
}); });
@@ -12,11 +12,11 @@ describe('getOptionsFromParams()', () => {
showGroups: true, showGroups: true,
speed: DEFAULT_SPEED, speed: DEFAULT_SPEED,
followLoaded: true, followLoaded: true,
fontSize: 64, fontSize: 40,
lineHeight: 1.5, lineHeight: 1.3,
textWidth: 90, textWidth: 80,
dimPast: true, dimPast: true,
readingLine: 'line', readingLine: true,
readingLinePos: 40, readingLinePos: 40,
flipH: false, flipH: false,
flipV: false, flipV: false,
@@ -30,7 +30,7 @@ describe('getOptionsFromParams()', () => {
test('booleans which default to true can be turned off', () => { test('booleans which default to true can be turned off', () => {
const options = getOptionsFromParams( const options = getOptionsFromParams(
new URLSearchParams('hideEmpty=false&showGroups=false&followLoaded=false&dimPast=false'), new URLSearchParams('hideEmpty=false&showGroups=false&followLoaded=false&dimPast=false&readingLine=false'),
); );
expect(options).toMatchObject({ expect(options).toMatchObject({
@@ -38,6 +38,7 @@ describe('getOptionsFromParams()', () => {
showGroups: false, showGroups: false,
followLoaded: false, followLoaded: false,
dimPast: false, dimPast: false,
readingLine: false,
}); });
}); });
@@ -59,21 +60,20 @@ describe('getOptionsFromParams()', () => {
// Number(null) is 0, so a naive parse would silently produce a speed of zero // Number(null) is 0, so a naive parse would silently produce a speed of zero
expect(getOptionsFromParams(new URLSearchParams('speed=fast')).speed).toBe(DEFAULT_SPEED); expect(getOptionsFromParams(new URLSearchParams('speed=fast')).speed).toBe(DEFAULT_SPEED);
expect(getOptionsFromParams(new URLSearchParams('speed=')).speed).toBe(DEFAULT_SPEED); expect(getOptionsFromParams(new URLSearchParams('speed=')).speed).toBe(DEFAULT_SPEED);
expect(getOptionsFromParams(new URLSearchParams('fontSize=huge')).fontSize).toBe(64); expect(getOptionsFromParams(new URLSearchParams('fontSize=huge')).fontSize).toBe(40);
}); });
test('rejects an unknown value for an enumerated option', () => { test('rejects an unknown value for an enumerated option', () => {
expect(getOptionsFromParams(new URLSearchParams('heading=banana')).heading).toBe('title'); expect(getOptionsFromParams(new URLSearchParams('heading=banana')).heading).toBe('title');
expect(getOptionsFromParams(new URLSearchParams('readingLine=banana')).readingLine).toBe('line');
}); });
test('preset values take precedence over the search params', () => { test('preset values take precedence over the search params', () => {
const options = getOptionsFromParams( const options = getOptionsFromParams(
new URLSearchParams('speed=10&script=custom-a'), new URLSearchParams('speed=10&script=custom-a'),
new URLSearchParams('speed=50&script=custom-b'), new URLSearchParams('speed=20&script=custom-b'),
); );
expect(options.speed).toBe(50); expect(options.speed).toBe(20);
expect(options.scriptSource).toBe('custom-b'); expect(options.scriptSource).toBe('custom-b');
}); });
}); });
@@ -1,3 +1,4 @@
import type { MouseEvent } from 'react';
import { IoAdd, IoArrowUp, IoHelpCircleOutline, IoLocate, IoPause, IoPlay, IoRemove } from 'react-icons/io5'; import { IoAdd, IoArrowUp, IoHelpCircleOutline, IoLocate, IoPause, IoPlay, IoRemove } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton'; import IconButton from '../../../common/components/buttons/IconButton';
@@ -33,12 +34,26 @@ export default function ControlOverlay({
}: ControlOverlayProps) { }: ControlOverlayProps) {
const isActive = useFadeOutOnInactivity(true); const isActive = useFadeOutOnInactivity(true);
/**
* A pointer press leaves focus on the button, where it would swallow the next
* Space: the operator taps pause, reaches for the pedal, and the script does
* not move. Space belongs to the transport, so the button hands focus back.
* A keyboard activation reports detail 0 and keeps its focus ring, since
* tabbing to a control only to have it drop out from under you is worse.
*/
const press = (action: () => void) => (event: MouseEvent<HTMLButtonElement>) => {
if (event.detail > 0) {
event.currentTarget.blur();
}
action();
};
return ( return (
<div className={cx(['teleprompter__controls', !isActive && 'teleprompter__controls--idle'])}> <div className={cx(['teleprompter__controls', !isActive && 'teleprompter__controls--idle'])}>
<IconButton <IconButton
variant='subtle-white' variant='subtle-white'
size='large' size='large'
onClick={controller.togglePlay} onClick={press(controller.togglePlay)}
data-testid='teleprompter-play' data-testid='teleprompter-play'
aria-label={isRunning ? 'Pause' : 'Play'} aria-label={isRunning ? 'Pause' : 'Play'}
> >
@@ -48,7 +63,7 @@ export default function ControlOverlay({
<IconButton <IconButton
variant='subtle-white' variant='subtle-white'
size='large' size='large'
onClick={() => controller.changeSpeed(-SPEED_STEP)} onClick={press(() => controller.changeSpeed(-SPEED_STEP))}
aria-label='Slow down' aria-label='Slow down'
> >
<IoRemove /> <IoRemove />
@@ -62,7 +77,7 @@ export default function ControlOverlay({
<IconButton <IconButton
variant='subtle-white' variant='subtle-white'
size='large' size='large'
onClick={() => controller.changeSpeed(SPEED_STEP)} onClick={press(() => controller.changeSpeed(SPEED_STEP))}
aria-label='Speed up' aria-label='Speed up'
> >
<IoAdd /> <IoAdd />
@@ -71,7 +86,7 @@ export default function ControlOverlay({
<IconButton <IconButton
variant={atEnd ? 'primary' : 'subtle-white'} variant={atEnd ? 'primary' : 'subtle-white'}
size='large' size='large'
onClick={() => controller.rewind()} onClick={press(() => controller.rewind())}
aria-label='Rewind to top' aria-label='Rewind to top'
> >
<IoArrowUp /> <IoArrowUp />
@@ -87,7 +102,7 @@ export default function ControlOverlay({
<IconButton <IconButton
variant='primary' variant='primary'
size='large' size='large'
onClick={controller.reengageFollow} onClick={press(controller.reengageFollow)}
data-testid='teleprompter-follow' data-testid='teleprompter-follow'
aria-label='Follow the loaded event' aria-label='Follow the loaded event'
> >
@@ -95,7 +110,7 @@ export default function ControlOverlay({
</IconButton> </IconButton>
)} )}
<IconButton variant='subtle-white' size='large' onClick={onToggleHelp} aria-label='Keyboard shortcuts'> <IconButton variant='subtle-white' size='large' onClick={press(onToggleHelp)} aria-label='Keyboard shortcuts'>
<IoHelpCircleOutline /> <IoHelpCircleOutline />
</IconButton> </IconButton>
</div> </div>
@@ -1,35 +1,23 @@
import { Dialog } from '@base-ui/react/dialog'; import { Dialog } from '@base-ui/react/dialog';
import type { PropsWithChildren } from 'react';
import Button from '../../../common/components/buttons/Button'; import Button from '../../../common/components/buttons/Button';
import Kbd from '../../../common/components/kbd/Kbd';
interface HelpOverlayProps { interface HelpOverlayProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
} }
const shortcuts: { keys: string; action: string }[] = [
{ keys: 'Space', action: 'Start / stop scrolling' },
{ keys: '← / →', action: 'Slower / faster (hold Shift for larger steps)' },
{ keys: '↑ / ↓', action: 'Nudge one line' },
{ keys: 'Page Up / Page Down', action: 'Jump a screen' },
{ keys: 'Home', action: 'Rewind to the top' },
{ keys: 'End', action: 'Jump to the end' },
{ keys: 'Esc', action: 'Rewind and stop' },
{ keys: '+ / -', action: 'Font size' },
{ keys: '0', action: 'Reset font size' },
{ keys: 'F / Shift + F', action: 'Flip horizontally / vertically' },
{ keys: 'L', action: 'Follow the loaded event again' },
{ keys: '?', action: 'Show this list' },
];
/** /**
* Most prompter foot pedals and hand controllers are USB HID devices which send
* these same keystrokes, so this list doubles as the hardware reference.
*
* Built on the shared Dialog rather than a bare overlay so it behaves as a * Built on the shared Dialog rather than a bare overlay so it behaves as a
* modal: focus moves into it, stays inside it, and returns to where it came * modal: focus moves into it, stays inside it, and returns to where it came
* from. Escape closes the dialog instead of rewinding the script, and the * from. Escape closes the dialog instead of rewinding the script, and the
* prompter keymap stands down for as long as it is open. * prompter keymap stands down for as long as it is open.
*
* Laid out as the rundown shortcuts panel is, down to the Kbd keycaps and the
* grouping, because it answers the same question and should not need to be
* learned twice.
*/ */
export default function HelpOverlay({ isOpen, onClose }: HelpOverlayProps) { export default function HelpOverlay({ isOpen, onClose }: HelpOverlayProps) {
return ( return (
@@ -44,18 +32,69 @@ export default function HelpOverlay({ isOpen, onClose }: HelpOverlayProps) {
<Dialog.Portal> <Dialog.Portal>
<Dialog.Backdrop className='teleprompter__help' /> <Dialog.Backdrop className='teleprompter__help' />
<Dialog.Popup className='teleprompter__help-card'> <Dialog.Popup className='teleprompter__help-card'>
<Dialog.Title className='teleprompter__help-title'>Controls</Dialog.Title> <Dialog.Title className='teleprompter__help-title'>Prompter shortcuts</Dialog.Title>
<dl className='teleprompter__help-list'>
{shortcuts.map(({ keys, action }) => ( <div className='teleprompter__help-groups'>
<div key={keys} className='teleprompter__help-row'> <ShortcutGroup title='Transport'>
<dt className='teleprompter__help-keys'>{keys}</dt> <Shortcut label='Start / stop scrolling'>
<dd className='teleprompter__help-action'>{action}</dd> <Combo keys={['Space']} />
</div> </Shortcut>
))} <Shortcut label='Slower / faster'>
</dl> <Combo keys={['←']} />
<div className='teleprompter__help-note'> <Separator />
Foot pedals and hand controllers which emit these keys work without any setup. <Combo keys={['→']} />
</Shortcut>
<Shortcut label='Larger speed steps'>
<Combo keys={['Shift', '←']} />
<Separator />
<Combo keys={['Shift', '→']} />
</Shortcut>
</ShortcutGroup>
<ShortcutGroup title='Navigation'>
<Shortcut label='Nudge one line'>
<Combo keys={['↑']} />
<Separator />
<Combo keys={['↓']} />
</Shortcut>
<Shortcut label='Jump a screen'>
<Combo keys={['PgUp']} />
<Separator />
<Combo keys={['PgDn']} />
</Shortcut>
<Shortcut label='Jump to top / end'>
<Combo keys={['Home']} />
<Separator />
<Combo keys={['End']} />
</Shortcut>
<Shortcut label='Rewind and stop'>
<Combo keys={['Esc']} />
</Shortcut>
<Shortcut label='Follow the loaded event again'>
<Combo keys={['L']} />
</Shortcut>
</ShortcutGroup>
<ShortcutGroup title='Display'>
<Shortcut label='Font size'>
<Combo keys={['+']} />
<Separator />
<Combo keys={['-']} />
</Shortcut>
<Shortcut label='Reset font size'>
<Combo keys={['0']} />
</Shortcut>
<Shortcut label='Flip horizontally / vertically'>
<Combo keys={['F']} />
<Separator />
<Combo keys={['Shift', 'F']} />
</Shortcut>
<Shortcut label='Show this list'>
<Combo keys={['?']} />
</Shortcut>
</ShortcutGroup>
</div> </div>
<Button variant='subtle-white' onClick={onClose} className='teleprompter__help-close'> <Button variant='subtle-white' onClick={onClose} className='teleprompter__help-close'>
Close Close
</Button> </Button>
@@ -64,3 +103,35 @@ export default function HelpOverlay({ isOpen, onClose }: HelpOverlayProps) {
</Dialog.Root> </Dialog.Root>
); );
} }
function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
return (
<section className='teleprompter__help-group'>
<h3 className='teleprompter__help-group-title'>{title}</h3>
<div className='teleprompter__help-list'>{children}</div>
</section>
);
}
function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className='teleprompter__help-row'>
<span className='teleprompter__help-label'>{label}</span>
<span className='teleprompter__help-keys'>{children}</span>
</div>
);
}
function Combo({ keys }: { keys: string[] }) {
return (
<span className='teleprompter__help-combo'>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</span>
);
}
function Separator() {
return <span className='teleprompter__help-separator'>/</span>;
}
@@ -1,31 +1,29 @@
import { cx } from '../../../common/utils/styleUtils';
import type { ReadingLineVariant } from '../teleprompter.types';
interface ReadingLineProps { interface ReadingLineProps {
variant: ReadingLineVariant; showReadingLine: boolean;
dimPast: boolean; dimPast: boolean;
} }
/** /**
* The eye-line indicator: it marks where on the screen the talent should read, * The eye-line indicator: it marks where on the screen the talent should read,
* which keeps their eyeline near the lens instead of tracking down the page. * which keeps their eyeline near the lens instead of tracking down the page.
*
* It is a marker beside the text rather than a rule across it. A full width line
* cuts through the words at the one place the eye is trying to rest, and every
* prompter which ships a cue indicator keeps it out of the reading path for the
* same reason. One line tall, so it frames the line being read rather than
* pointing at a position between two of them.
*/ */
export default function ReadingLine({ variant, dimPast }: ReadingLineProps) { export default function ReadingLine({ showReadingLine, dimPast }: ReadingLineProps) {
if (variant === 'none' && !dimPast) { if (!showReadingLine && !dimPast) {
return null; return null;
} }
return ( return (
<> <>
{dimPast && <div className='teleprompter__dim' />} {dimPast && <div className='teleprompter__dim' />}
{variant !== 'none' && ( {showReadingLine && (
<div className={cx(['teleprompter__reading-line', `teleprompter__reading-line--${variant}`])}> <div className='teleprompter__reading-line'>
{variant === 'arrows' && ( <span className='teleprompter__reading-marker' />
<>
<span className='teleprompter__arrow teleprompter__arrow--left' />
<span className='teleprompter__arrow teleprompter__arrow--right' />
</>
)}
</div> </div>
)} )}
</> </>
@@ -8,7 +8,7 @@ import { makeOptionsFromCustomFields } from '../../common/components/view-params
import { PresetContext } from '../../common/context/PresetContext'; import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../common/viewUtils'; import { isStringBoolean } from '../common/viewUtils';
import { clampSpeed, DEFAULT_SPEED, MAX_SPEED, MIN_SPEED } from './teleprompter.scroll'; import { clampSpeed, DEFAULT_SPEED, MAX_SPEED, MIN_SPEED } from './teleprompter.scroll';
import type { HeadingSource, ReadingLineVariant, TeleprompterOptions } from './teleprompter.types'; import type { HeadingSource, TeleprompterOptions } from './teleprompter.types';
/** /**
* The values the editor offers are also the values the parser accepts, so the * The values the editor offers are also the values the parser accepts, so the
@@ -24,13 +24,6 @@ const headingOptions: { value: HeadingSource; label: string }[] = [
]; ];
const headingSources = headingOptions.map((option) => option.value); const headingSources = headingOptions.map((option) => option.value);
const readingLineOptions: { value: ReadingLineVariant; label: string }[] = [
{ value: 'line', label: 'Line' },
{ value: 'arrows', label: 'Arrows' },
{ value: 'none', label: 'None' },
];
const readingLineVariants = readingLineOptions.map((option) => option.value);
/** /**
* Defaults and bounds for every option, in one place. * Defaults and bounds for every option, in one place.
* *
@@ -45,10 +38,10 @@ const defaults = {
showGroups: true, showGroups: true,
speed: DEFAULT_SPEED, speed: DEFAULT_SPEED,
followLoaded: true, followLoaded: true,
fontSize: 64, fontSize: 40,
lineHeight: 1.5, lineHeight: 1.3,
textWidth: 90, textWidth: 80,
readingLine: 'line' as ReadingLineVariant, readingLine: true,
readingLinePos: 40, readingLinePos: 40,
dimPast: true, dimPast: true,
flipH: false, flipH: false,
@@ -162,9 +155,8 @@ export const getTeleprompterOptions = (customFields: CustomFields): ViewOption[]
{ {
id: 'readingLine', id: 'readingLine',
title: 'Reading line', title: 'Reading line',
description: 'Style of the eye-line indicator which marks where to read', description: 'Shows a marker beside the line which should be read',
type: 'option', type: 'boolean',
values: readingLineOptions,
defaultValue: defaults.readingLine, defaultValue: defaults.readingLine,
}, },
{ {
@@ -245,7 +237,7 @@ export function getOptionsFromParams(
lineHeight: toNumber(getValue('lineHeight'), bounds.lineHeight, defaults.lineHeight), lineHeight: toNumber(getValue('lineHeight'), bounds.lineHeight, defaults.lineHeight),
textWidth: toNumber(getValue('textWidth'), bounds.textWidth, defaults.textWidth), textWidth: toNumber(getValue('textWidth'), bounds.textWidth, defaults.textWidth),
dimPast: toBoolean(getValue('dimPast'), defaults.dimPast), dimPast: toBoolean(getValue('dimPast'), defaults.dimPast),
readingLine: toEnum(getValue('readingLine'), readingLineVariants, defaults.readingLine), readingLine: toBoolean(getValue('readingLine'), defaults.readingLine),
readingLinePos: toNumber(getValue('readingLinePos'), bounds.readingLinePos, defaults.readingLinePos), readingLinePos: toNumber(getValue('readingLinePos'), bounds.readingLinePos, defaults.readingLinePos),
flipH: toBoolean(getValue('flipH'), defaults.flipH), flipH: toBoolean(getValue('flipH'), defaults.flipH),
flipV: toBoolean(getValue('flipV'), defaults.flipV), flipV: toBoolean(getValue('flipV'), defaults.flipV),
@@ -5,14 +5,23 @@
* The hook that owns the requestAnimationFrame loop is the only caller. * The hook that owns the requestAnimationFrame loop is the only caller.
*/ */
/** lines per minute */ /**
export const MIN_SPEED = 2; * Lines per minute.
export const MAX_SPEED = 200; *
export const DEFAULT_SPEED = 30; * The default is calibrated against the reading rate rather than picked for
* feel: broadcast presenters read at 140-160 words per minute and conference
* talent slower still, and at the default column width a line carries a dozen
* or so words. Twelve lines per minute lands in that band. The ceiling is set
* where the text stops being readable at all, not at the fastest the loop can
* physically scroll, so the arrow keys stay useful across their whole range.
*/
export const MIN_SPEED = 1;
export const MAX_SPEED = 40;
export const DEFAULT_SPEED = 12;
/** how much one speed adjustment moves, shared by the keymap and the overlay */ /** how much one speed adjustment moves, shared by the keymap and the overlay */
export const SPEED_STEP = 2; export const SPEED_STEP = 1;
export const SPEED_STEP_COARSE = 10; export const SPEED_STEP_COARSE = 5;
/** font size multiplier applied on top of the configured size by the +/- keys */ /** font size multiplier applied on top of the configured size by the +/- keys */
const MIN_FONT_SCALE = 0.4; const MIN_FONT_SCALE = 0.4;
@@ -3,13 +3,6 @@ import type { MaybeString } from 'ontime-types';
/** What the per-event heading shows above each script block */ /** What the per-event heading shows above each script block */
export type HeadingSource = 'none' | 'title' | 'cue' | 'both'; export type HeadingSource = 'none' | 'title' | 'cue' | 'both';
/**
* Style of the eye-line indicator.
* Darkening what has already been read is the separate dimPast option, so there
* is deliberately no shade variant here: it would be the same element twice.
*/
export type ReadingLineVariant = 'none' | 'line' | 'arrows';
/** A single readable segment of the prompter document */ /** A single readable segment of the prompter document */
export type ScriptBlock = { export type ScriptBlock = {
/** entry id, used as the follow target and the react key */ /** entry id, used as the follow target and the react key */
@@ -37,7 +30,7 @@ export type TeleprompterOptions = {
lineHeight: number; lineHeight: number;
textWidth: number; textWidth: number;
dimPast: boolean; dimPast: boolean;
readingLine: ReadingLineVariant; readingLine: boolean;
readingLinePos: number; readingLinePos: number;
flipH: boolean; flipH: boolean;
flipV: boolean; flipV: boolean;
@@ -75,12 +75,13 @@ export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
} }
/** /**
* A focused button is activated by Space and Enter. Resolving those into * Enter is left to a focused control so the overlay stays operable from
* prompter actions here, and calling preventDefault, would stop the button * the keyboard. Space is not: it is the play/pause pedal, and a prompter
* doing its own job: tabbing to the help control and pressing Space would * which stops rolling because the operator last touched a button would be
* start the script rather than open the help. * broken in the one moment it matters. The overlay drops focus after a
* click so the two never compete for the same press.
*/ */
if ((event.code === 'Space' || event.key === 'Enter') && target?.closest('button, a, [role="button"]')) { if (event.key === 'Enter' && target?.closest('button, a, [role="button"]')) {
return; return;
} }
@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { seedScript } from '../utils/seedScript';
test('View params configures timer view', async ({ page }) => { test('View params configures timer view', async ({ page }) => {
await page.goto('/timer'); await page.goto('/timer');
@@ -13,3 +15,29 @@ test('View params configures timer view', async ({ page }) => {
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport(); await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
await expect(page).toHaveURL(/.*hideClock=true/); await expect(page).toHaveURL(/.*hideClock=true/);
}); });
/**
* An option which defaults to true is the case which breaks: the switch has to
* open on, and switching it off has to reach the URL. A checkbox which is off
* sends nothing at all, so an option in that state is only representable by
* writing it out explicitly.
*/
test('View params can switch off an option which defaults to on', async ({ page }) => {
await seedScript(page);
await page.goto('/teleprompter?script=note');
const readingMarker = page.locator('.teleprompter__reading-marker');
await expect(readingMarker).toBeVisible();
await page.mouse.move(Math.random() * 100, Math.random() * 100);
await page.getByTestId('navigation__toggle-settings').click();
const readingLineSwitch = page.locator('label:has(input[name="readingLine"]) [role="switch"]');
await expect(readingLineSwitch).toHaveAttribute('aria-checked', 'true');
await readingLineSwitch.click();
await page.getByTestId('apply-view-params').click();
await expect(page).toHaveURL(/.*readingLine=false/);
await expect(readingMarker).toHaveCount(0);
});
+6 -24
View File
@@ -1,15 +1,13 @@
import { type Page, expect, test } from '@playwright/test'; import { type Page, expect, test } from '@playwright/test';
import { scriptMarker, seedScript } from '../utils/seedScript';
/** /**
* The note field is the script source throughout, so that these tests do not * The note field is the script source throughout, so that these tests do not
* depend on how custom field keys happen to be spelled. * depend on how custom field keys happen to be spelled.
*/ */
const teleprompterUrl = '/teleprompter?script=note'; const teleprompterUrl = '/teleprompter?script=note';
const scriptMarker = 'E2E prompter script';
/** long enough that the document scrolls well past a screen */
const scriptText = `${scriptMarker}. `.repeat(40);
function scroller(page: Page) { function scroller(page: Page) {
return page.getByTestId('teleprompter-scroller'); return page.getByTestId('teleprompter-scroller');
} }
@@ -18,25 +16,6 @@ function scrollTop(page: Page) {
return scroller(page).evaluate((element) => element.scrollTop); return scroller(page).evaluate((element) => element.scrollTop);
} }
/**
* Puts a known script into whichever rundown happens to be loaded.
*
* These tests used to read the notes of the uploaded fixture, which made them
* depend on every spec that runs before them: 214 creates a fresh rundown and
* leaves it loaded, so by the time this file ran there were no notes anywhere
* and the view was showing its empty state. Seeding is idempotent, so the
* rundown gains one event no matter how many tests run.
*/
async function seedScript(page: Page) {
const rundown = await (await page.request.get('/data/rundowns/current')).json();
const alreadySeeded = rundown.flatOrder.some((id: string) => rundown.entries[id]?.note?.startsWith(scriptMarker));
if (alreadySeeded) return;
await page.request.post(`/data/rundowns/${rundown.id}/entry`, {
data: { type: 'event', title: 'Teleprompter e2e', note: scriptText },
});
}
test.describe('teleprompter', () => { test.describe('teleprompter', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await seedScript(page); await seedScript(page);
@@ -150,11 +129,14 @@ test.describe('teleprompter', () => {
}); });
test('honours the flip and reading line params', async ({ page }) => { test('honours the flip and reading line params', async ({ page }) => {
await page.goto('/teleprompter?script=note&flipV=true&readingLine=arrows'); await page.goto('/teleprompter?script=note&flipV=true&readingLine=false');
const view = page.getByTestId('teleprompter-view'); const view = page.getByTestId('teleprompter-view');
const transform = await view.evaluate((element) => getComputedStyle(element).transform); const transform = await view.evaluate((element) => getComputedStyle(element).transform);
// a vertical flip leaves x positive and makes the y scale negative // a vertical flip leaves x positive and makes the y scale negative
expect(transform).toMatch(/^matrix\(1, 0, 0, -1/); expect(transform).toMatch(/^matrix\(1, 0, 0, -1/);
// a boolean which defaults to true has to be switchable off from the url
await expect(page.locator('.teleprompter__reading-marker')).toHaveCount(0);
}); });
}); });
+24
View File
@@ -0,0 +1,24 @@
import type { Page } from '@playwright/test';
export const scriptMarker = 'E2E prompter script';
/** long enough that the document scrolls well past a screen */
export const scriptText = `${scriptMarker}. `.repeat(40);
/**
* Puts a known script into whichever rundown happens to be loaded.
*
* Specs which read the rundown cannot rely on the uploaded fixture surviving:
* the suite runs serially and earlier specs add, edit and delete entries, while
* 214 creates a fresh rundown and leaves it loaded. Seeding is idempotent, so
* the rundown gains one event no matter how many tests have run before.
*/
export async function seedScript(page: Page) {
const rundown = await (await page.request.get('/data/rundowns/current')).json();
const alreadySeeded = rundown.flatOrder.some((id: string) => rundown.entries[id]?.note?.startsWith(scriptMarker));
if (alreadySeeded) return;
await page.request.post(`/data/rundowns/${rundown.id}/entry`, {
data: { type: 'event', title: 'Teleprompter e2e', note: scriptText },
});
}