refactor(teleprompter): share the shortcut list, drop the dim option

The help dialog had been laid out to match the rundown editor's shortcut
list by copying its structure and its stylesheet. The pieces both were built
from now live together, so the two lists cannot drift: the editor keeps its
palette, the viewer keeps its own, and everything else is shared.

Dimming what has already been read is no longer a setting. Prompter software
treats the cue marker and the shading as two halves of one reading guide, and
this was a switch with no reason to be touched. Removing it takes the option,
its parse, its type and the prop with it.

The reading line moves to a quarter from the top. It leaves more of the
script visible below the line, which is the part being read towards.

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-19 11:12:43 +00:00
parent 15c898259f
commit 583ece764d
11 changed files with 165 additions and 205 deletions
@@ -0,0 +1,75 @@
/**
* Layout for a list of keyboard shortcuts, shared by the rundown editor and the
* teleprompter help.
*
* Colour is left to whoever renders it: the editor and the viewer palettes have
* no tokens in common, and the only thing the two lists actually share is the
* shape. The three custom properties below are the seams for that.
*/
.groups {
display: grid;
gap: 0.875rem;
}
.group {
h3 {
margin: 0 0 0.375rem;
color: var(--shortcut-title-color, currentColor);
font-size: calc(1rem - 3px);
font-weight: 600;
text-transform: uppercase;
}
}
.list {
display: grid;
gap: 0.25rem;
}
.row {
min-height: 1.625rem;
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
align-items: center;
gap: 0.75rem;
font-size: calc(1rem - 3px);
}
.label {
min-width: 0;
line-height: 1.2;
color: var(--shortcut-label-color, currentColor);
}
.keys {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.25rem 0.5rem;
min-width: 0;
}
.combo {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.25rem 0;
}
.separator {
color: var(--shortcut-separator-color, currentColor);
font-size: calc(1rem - 5px);
}
/* the label column cannot hold a sentence and a key combo side by side on a phone */
@media (max-width: 680px) {
.row {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.keys {
justify-content: flex-start;
}
}
@@ -0,0 +1,52 @@
import type { PropsWithChildren } from 'react';
import { cx } from '../../utils/styleUtils';
import Kbd from '../kbd/Kbd';
import style from './KeyboardShortcuts.module.scss';
/**
* The pieces a list of keyboard shortcuts is built from.
*
* Both the rundown editor's empty state and the teleprompter's help answer the
* same question, so they are laid out by the same components: a reader who has
* learned one list can read the other. Only the palette is left to the host,
* through the --shortcut-*-color properties, since the editor and the viewer
* themes share no tokens.
*/
export function ShortcutGroups({ className, children }: PropsWithChildren<{ className?: string }>) {
return <div className={cx([style.groups, className])}>{children}</div>;
}
export function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
return (
<section className={style.group}>
<h3>{title}</h3>
<div className={style.list}>{children}</div>
</section>
);
}
export function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.row}>
<span className={style.label}>{label}</span>
<span className={style.keys}>{children}</span>
</div>
);
}
/** One chord, rendered as keycaps. Several in a row read as alternatives */
export function Combo({ keys }: { keys: string[] }) {
return (
<span className={style.combo}>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</span>
);
}
export function Separator() {
return <span className={style.separator}>/</span>;
}
@@ -17,59 +17,10 @@
}
.shortcuts {
display: grid;
gap: 0.875rem;
margin-top: 0.875rem;
}
.shortcutGroup {
h3 {
margin: 0 0 0.375rem;
color: $ui-white;
font-size: calc(1rem - 3px);
font-weight: 600;
text-transform: uppercase;
}
}
.shortcutList {
display: grid;
gap: 0.25rem;
}
.shortcutRow {
min-height: 1.625rem;
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
align-items: center;
gap: 0.75rem;
font-size: calc(1rem - 3px);
}
.shortcutLabel {
min-width: 0;
line-height: 1.2;
}
.shortcutKeys {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.25rem 0.5rem;
min-width: 0;
}
.keyCombo {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.25rem 0;
}
.separator {
color: $gray-500;
font-size: calc(1rem - 5px);
--shortcut-title-color: #{$ui-white};
--shortcut-separator-color: #{$gray-500};
}
.prompt {
@@ -84,13 +35,4 @@
.shortcutSection {
margin-top: 1rem;
}
.shortcutRow {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.shortcutKeys {
justify-content: flex-start;
}
}
@@ -1,7 +1,13 @@
import { PropsWithChildren, memo } from 'react';
import { memo } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Kbd from '../../../common/components/kbd/Kbd';
import {
Combo,
Separator,
Shortcut,
ShortcutGroup,
ShortcutGroups,
} from '../../../common/components/keyboard-shortcuts/KeyboardShortcuts';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import style from './EventEditorEmpty.module.scss';
@@ -13,7 +19,7 @@ function EventEditorEmpty() {
<div className={style.entryEditor} data-testid='editor-container'>
<div className={style.shortcutSection}>
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
<div className={style.shortcuts}>
<ShortcutGroups className={style.shortcuts}>
<ShortcutGroup title='Search'>
<Shortcut label='Find in rundown'>
<Combo keys={[deviceMod, 'F']} />
@@ -100,40 +106,8 @@ function EventEditorEmpty() {
<Combo keys={[deviceAlt, 'Shift', 'D']} />
</Shortcut>
</ShortcutGroup>
</div>
</ShortcutGroups>
</div>
</div>
);
}
function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
return (
<section className={style.shortcutGroup}>
<h3>{title}</h3>
<div className={style.shortcutList}>{children}</div>
</section>
);
}
function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.shortcutRow}>
<span className={style.shortcutLabel}>{label}</span>
<span className={style.shortcutKeys}>{children}</span>
</div>
);
}
function Combo({ keys }: { keys: string[] }) {
return (
<span className={style.keyCombo}>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</span>
);
}
function Separator() {
return <span className={style.separator}>/</span>;
}
@@ -267,56 +267,9 @@
font-weight: 600;
}
/* the shared shortcut list owns its layout, the view supplies the palette */
.teleprompter__help-groups {
display: grid;
gap: 1.25rem;
}
.teleprompter__help-group-title {
margin: 0 0 0.5rem;
font-size: calc(1rem - 3px);
font-weight: 600;
text-transform: uppercase;
color: $viewer-label-color;
}
.teleprompter__help-list {
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);
--shortcut-title-color: #{$viewer-label-color};
--shortcut-label-color: #{$viewer-secondary-color};
--shortcut-separator-color: #{$viewer-label-color};
}
@@ -184,7 +184,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
</div>
</div>
<ReadingLine showReadingLine={options.readingLine} dimPast={options.dimPast} />
<ReadingLine showReadingLine={options.readingLine} />
<ControlOverlay
isRunning={isRunning}
@@ -15,9 +15,8 @@ describe('getOptionsFromParams()', () => {
fontSize: 52,
lineHeight: 1.3,
textWidth: 80,
dimPast: true,
readingLine: true,
readingLinePos: 40,
readingLinePos: 25,
flipH: false,
flipV: false,
});
@@ -30,14 +29,13 @@ describe('getOptionsFromParams()', () => {
test('booleans which default to true can be turned off', () => {
const options = getOptionsFromParams(
new URLSearchParams('hideEmpty=false&showGroups=false&followLoaded=false&dimPast=false&readingLine=false'),
new URLSearchParams('hideEmpty=false&showGroups=false&followLoaded=false&readingLine=false'),
);
expect(options).toMatchObject({
hideEmpty: false,
showGroups: false,
followLoaded: false,
dimPast: false,
readingLine: false,
});
});
@@ -1,9 +1,14 @@
import { Dialog } from '@base-ui/react/dialog';
import type { PropsWithChildren } from 'react';
import { IoClose } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Kbd from '../../../common/components/kbd/Kbd';
import {
Combo,
Separator,
Shortcut,
ShortcutGroup,
ShortcutGroups,
} from '../../../common/components/keyboard-shortcuts/KeyboardShortcuts';
interface HelpOverlayProps {
isOpen: boolean;
@@ -16,9 +21,8 @@ interface HelpOverlayProps {
* from. Escape closes the dialog instead of rewinding the script, and the
* 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.
* Built from the same components as the rundown editor's shortcut list, because
* it answers the same question and should not have to be learned twice.
*/
export default function HelpOverlay({ isOpen, onClose }: HelpOverlayProps) {
return (
@@ -40,7 +44,7 @@ export default function HelpOverlay({ isOpen, onClose }: HelpOverlayProps) {
</IconButton>
</div>
<div className='teleprompter__help-groups'>
<ShortcutGroups className='teleprompter__help-groups'>
<ShortcutGroup title='Transport'>
<Shortcut label='Start / stop scrolling'>
<Combo keys={['Space']} />
@@ -99,41 +103,9 @@ export default function HelpOverlay({ isOpen, onClose }: HelpOverlayProps) {
<Combo keys={['?']} />
</Shortcut>
</ShortcutGroup>
</div>
</ShortcutGroups>
</Dialog.Popup>
</Dialog.Portal>
</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,6 +1,5 @@
interface ReadingLineProps {
showReadingLine: boolean;
dimPast: boolean;
}
/**
@@ -12,11 +11,16 @@ interface ReadingLineProps {
* 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.
*
* The fade over what has already been read is not optional. Prompter software
* treats the marker and the shading as two halves of one reading guide, and a
* shade which can be switched off is a setting nobody has a reason to change on
* a black on white script.
*/
export default function ReadingLine({ showReadingLine, dimPast }: ReadingLineProps) {
export default function ReadingLine({ showReadingLine }: ReadingLineProps) {
return (
<>
{dimPast && <div className='teleprompter__dim' />}
<div className='teleprompter__dim' />
{showReadingLine && (
<div className='teleprompter__reading-line'>
<span className='teleprompter__reading-marker' />
@@ -42,8 +42,7 @@ export const defaults = {
lineHeight: 1.3,
textWidth: 80,
readingLine: true,
readingLinePos: 40,
dimPast: true,
readingLinePos: 25,
flipH: false,
flipV: false,
};
@@ -166,13 +165,6 @@ export const getTeleprompterOptions = (customFields: CustomFields): ViewOption[]
type: 'number',
defaultValue: defaults.readingLinePos,
},
{
id: 'dimPast',
title: 'Dim text already read',
description: 'Fades the text above the reading line',
type: 'boolean',
defaultValue: defaults.dimPast,
},
{
id: 'flipH',
title: 'Flip horizontally',
@@ -236,7 +228,6 @@ export function getOptionsFromParams(
fontSize: toNumber(getValue('fontSize'), bounds.fontSize, defaults.fontSize),
lineHeight: toNumber(getValue('lineHeight'), bounds.lineHeight, defaults.lineHeight),
textWidth: toNumber(getValue('textWidth'), bounds.textWidth, defaults.textWidth),
dimPast: toBoolean(getValue('dimPast'), defaults.dimPast),
readingLine: toBoolean(getValue('readingLine'), defaults.readingLine),
readingLinePos: toNumber(getValue('readingLinePos'), bounds.readingLinePos, defaults.readingLinePos),
flipH: toBoolean(getValue('flipH'), defaults.flipH),
@@ -29,7 +29,6 @@ export type TeleprompterOptions = {
fontSize: number;
lineHeight: number;
textWidth: number;
dimPast: boolean;
readingLine: boolean;
readingLinePos: number;
flipH: boolean;