export type AnimationAccessibilityCommand = | 'togglePlayback' | 'restart' | 'stepForward' | 'stepBackward' | 'seekForwardSmall' | 'seekBackwardSmall' | 'seekForwardLarge' | 'seekBackwardLarge' | 'increaseRpm' | 'decreaseRpm' | 'increaseTimeScale' | 'decreaseTimeScale' | 'nextTourStep' | 'previousTourStep' | 'toggleLabels' | 'toggleExplodedView' | 'toggleHelp'; export type ShortcutGroup = 'playback' | 'stepping' | 'speed' | 'tour' | 'view' | 'help'; export type ShortcutDisplayPlatform = 'generic' | 'mac' | 'windows' | 'linux'; export interface KeyboardShortcutEventLike { key?: string | null | undefined; code?: string | null | undefined; altKey?: boolean | undefined; ctrlKey?: boolean | undefined; metaKey?: boolean | undefined; shiftKey?: boolean | undefined; repeat?: boolean | undefined; defaultPrevented?: boolean | undefined; target?: unknown; currentTarget?: unknown; preventDefault?: (() => void) | undefined; stopPropagation?: (() => void) | undefined; } export interface ShortcutDefinition { id: string; command: AnimationAccessibilityCommand; key: string; code?: string | undefined; group: ShortcutGroup; description: string; ariaLabel?: string | undefined; altKey?: boolean | undefined; ctrlKey?: boolean | undefined; metaKey?: boolean | undefined; shiftKey?: boolean | undefined; allowRepeat?: boolean | undefined; allowInEditable?: boolean | undefined; preventDefault?: boolean | undefined; disabled?: boolean | undefined; } export interface ShortcutMatchOptions { allowInEditable?: boolean | undefined; includeDisabled?: boolean | undefined; ignoreDefaultPrevented?: boolean | undefined; } export type AnimationShortcutAction = ( event: KeyboardShortcutEventLike, shortcut: ShortcutDefinition, ) => void; export type AnimationShortcutActions = Partial< Record >; export interface AnimationShortcutHandlerOptions extends ShortcutMatchOptions { shortcuts?: readonly ShortcutDefinition[] | undefined; enabled?: boolean | (() => boolean) | undefined; stopPropagation?: boolean | undefined; onCommand?: | (( command: AnimationAccessibilityCommand, event: KeyboardShortcutEventLike, shortcut: ShortcutDefinition, ) => void) | undefined; } export interface ShortcutHelpRow { id: string; command: AnimationAccessibilityCommand; group: ShortcutGroup; chord: string; ariaKeyShortcut: string; description: string; ariaLabel: string; } export type ReducedMotionPlaybackMode = | 'normal' | 'static-snapshot' | 'step-through' | 'reduced-continuous'; export interface ReducedMotionAnimationPlanInput { prefersReducedMotion: boolean; userInitiated?: boolean | undefined; essentialMotion?: boolean | undefined; allowContinuousReducedMotion?: boolean | undefined; requestedTimeScale?: number | undefined; maxReducedTimeScale?: number | undefined; reducedRpmMultiplier?: number | undefined; } export interface ReducedMotionAnimationPlan { mode: ReducedMotionPlaybackMode; shouldAutoPlay: boolean; allowContinuousPlayback: boolean; timeScale: number; rpmMultiplier: number; reason: string; } export type AnimationPlaybackAnnouncementState = | 'idle' | 'playing' | 'paused' | 'stopped' | 'stepping' | 'ended'; export interface AnimationStatusAnnouncementInput { machineName?: string | undefined; playbackState?: AnimationPlaybackAnnouncementState | undefined; isPlaying?: boolean | undefined; rpm?: number | undefined; timeScale?: number | undefined; loop?: boolean | undefined; stepMode?: boolean | undefined; reducedMotion?: boolean | undefined; progress?: number | undefined; tourTitle?: string | undefined; tourStepTitle?: string | undefined; currentStep?: number | undefined; totalSteps?: number | undefined; highlightedPartName?: string | undefined; } export interface AnimationControlValueRange { min: number; max: number; step?: number | undefined; fallback?: number | undefined; } const EDITABLE_TAG_NAMES = new Set(['INPUT', 'TEXTAREA', 'SELECT', 'OPTION']); const EDITABLE_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'spinbutton', 'slider']); const GROUP_SORT_ORDER: Record = { playback: 0, stepping: 1, speed: 2, tour: 3, view: 4, help: 5, }; const META_LABEL: Record = { generic: 'Meta', mac: '⌘', windows: 'Win', linux: 'Meta', }; export const DEFAULT_ANIMATION_SHORTCUTS: readonly ShortcutDefinition[] = Object.freeze([ { id: 'toggle-playback-space', command: 'togglePlayback', key: ' ', code: 'Space', group: 'playback', description: 'Play or pause the machine animation.', }, { id: 'toggle-playback-k', command: 'togglePlayback', key: 'k', group: 'playback', description: 'Play or pause the machine animation.', }, { id: 'restart-r', command: 'restart', key: 'r', group: 'playback', description: 'Restart the current animation cycle.', }, { id: 'step-forward-period', command: 'stepForward', key: '.', group: 'stepping', description: 'Advance one deterministic animation step.', allowRepeat: true, }, { id: 'step-backward-comma', command: 'stepBackward', key: ',', group: 'stepping', description: 'Move back one deterministic animation step.', allowRepeat: true, }, { id: 'seek-backward-small', command: 'seekBackwardSmall', key: 'ArrowLeft', group: 'stepping', description: 'Seek backward by a small increment.', allowRepeat: true, }, { id: 'seek-forward-small', command: 'seekForwardSmall', key: 'ArrowRight', group: 'stepping', description: 'Seek forward by a small increment.', allowRepeat: true, }, { id: 'seek-backward-large', command: 'seekBackwardLarge', key: 'ArrowLeft', shiftKey: true, group: 'stepping', description: 'Seek backward by a larger increment.', allowRepeat: true, }, { id: 'seek-forward-large', command: 'seekForwardLarge', key: 'ArrowRight', shiftKey: true, group: 'stepping', description: 'Seek forward by a larger increment.', allowRepeat: true, }, { id: 'increase-rpm', command: 'increaseRpm', key: 'ArrowUp', altKey: true, group: 'speed', description: 'Increase machine RPM.', allowRepeat: true, }, { id: 'decrease-rpm', command: 'decreaseRpm', key: 'ArrowDown', altKey: true, group: 'speed', description: 'Decrease machine RPM.', allowRepeat: true, }, { id: 'increase-time-scale', command: 'increaseTimeScale', key: ']', group: 'speed', description: 'Increase animation time scale.', allowRepeat: true, }, { id: 'decrease-time-scale', command: 'decreaseTimeScale', key: '[', group: 'speed', description: 'Decrease animation time scale.', allowRepeat: true, }, { id: 'next-tour-step', command: 'nextTourStep', key: 'n', group: 'tour', description: 'Move to the next guided-tour step.', }, { id: 'previous-tour-step', command: 'previousTourStep', key: 'p', group: 'tour', description: 'Move to the previous guided-tour step.', }, { id: 'toggle-labels', command: 'toggleLabels', key: 'l', group: 'view', description: 'Show or hide engineering labels.', }, { id: 'toggle-exploded-view', command: 'toggleExplodedView', key: 'e', group: 'view', description: 'Toggle exploded assembly view.', }, { id: 'toggle-help', command: 'toggleHelp', key: '?', shiftKey: true, group: 'help', description: 'Show or hide the keyboard shortcut reference.', }, ]); export function normaliseKeyboardKey(key: string | null | undefined): string { if (key == null) { return ''; } if (key === ' ' || key === 'Spacebar') { return 'space'; } const trimmed = key.trim(); if (!trimmed) { return ''; } const lower = trimmed.toLowerCase(); switch (lower) { case 'space': return 'space'; case 'esc': return 'escape'; case 'left': return 'arrowleft'; case 'right': return 'arrowright'; case 'up': return 'arrowup'; case 'down': return 'arrowdown'; case 'unidentified': return ''; default: return lower; } } export function isEditableShortcutTarget(target: unknown): boolean { if (!target || typeof target !== 'object') { return false; } const tagName = String( readRecordValue(target, 'tagName') ?? readRecordValue(target, 'nodeName') ?? '', ).toUpperCase(); if (EDITABLE_TAG_NAMES.has(tagName)) { if (tagName === 'INPUT') { const type = readAttribute(target, 'type')?.toLowerCase(); return type !== 'hidden'; } return true; } if (readRecordValue(target, 'isContentEditable') === true) { return true; } const contentEditable = readAttribute(target, 'contenteditable')?.toLowerCase(); if (contentEditable === 'true' || contentEditable === '') { return true; } const role = readAttribute(target, 'role')?.toLowerCase(); if (role && EDITABLE_ROLES.has(role)) { return true; } const closest = readRecordValue(target, 'closest'); if (typeof closest === 'function') { try { return Boolean( closest.call( target, [ 'input', 'textarea', 'select', '[contenteditable=""]', '[contenteditable="true"]', '[role="textbox"]', '[role="searchbox"]', '[role="combobox"]', '[role="spinbutton"]', '[role="slider"]', ].join(','), ), ); } catch { return false; } } return false; } export function matchesShortcut( event: KeyboardShortcutEventLike, shortcut: ShortcutDefinition, ): boolean { if (shortcut.disabled) { return false; } if (event.repeat && shortcut.allowRepeat !== true) { return false; } if (!modifiersMatch(event, shortcut)) { return false; } const eventKey = normaliseKeyboardKey(event.key); const shortcutKey = normaliseKeyboardKey(shortcut.key); if (eventKey && shortcutKey && eventKey === shortcutKey) { return true; } const eventCode = normaliseKeyboardCode(event.code); const shortcutCode = normaliseKeyboardCode(shortcut.code); return !eventKey && Boolean(eventCode && shortcutCode && eventCode === shortcutCode); } export function findMatchingShortcut( event: KeyboardShortcutEventLike, shortcuts: readonly ShortcutDefinition[] = DEFAULT_ANIMATION_SHORTCUTS, options: ShortcutMatchOptions = {}, ): ShortcutDefinition | undefined { if (event.defaultPrevented && options.ignoreDefaultPrevented !== true) { return undefined; } const editableTarget = isEditableShortcutTarget(event.target); for (const shortcut of shortcuts) { if (shortcut.disabled && options.includeDisabled !== true) { continue; } if (editableTarget && shortcut.allowInEditable !== true && options.allowInEditable !== true) { continue; } if (matchesShortcut(event, shortcut)) { return shortcut; } } return undefined; } export function createAnimationShortcutHandler( actions: AnimationShortcutActions, options: AnimationShortcutHandlerOptions = {}, ): (event: KeyboardShortcutEventLike) => AnimationAccessibilityCommand | undefined { return (event: KeyboardShortcutEventLike) => { const enabled = typeof options.enabled === 'function' ? options.enabled() : options.enabled; if (enabled === false) { return undefined; } const shortcut = findMatchingShortcut(event, options.shortcuts ?? DEFAULT_ANIMATION_SHORTCUTS, { allowInEditable: options.allowInEditable, includeDisabled: options.includeDisabled, ignoreDefaultPrevented: options.ignoreDefaultPrevented, }); if (!shortcut) { return undefined; } const action = actions[shortcut.command]; const hasCommandHandler = Boolean(action || options.onCommand); if (!hasCommandHandler) { return undefined; } if (shortcut.preventDefault !== false) { event.preventDefault?.(); } if (options.stopPropagation) { event.stopPropagation?.(); } action?.(event, shortcut); options.onCommand?.(shortcut.command, event, shortcut); return shortcut.command; }; } export function keyboardKeyLabel(key: string): string { const normalised = normaliseKeyboardKey(key); switch (normalised) { case 'space': return 'Space'; case 'arrowleft': return '←'; case 'arrowright': return '→'; case 'arrowup': return '↑'; case 'arrowdown': return '↓'; case 'escape': return 'Esc'; case 'enter': return 'Enter'; case 'tab': return 'Tab'; default: if (normalised.length === 1) { return normalised.toUpperCase(); } return normalised.charAt(0).toUpperCase() + normalised.slice(1); } } export function formatShortcutChord( shortcut: ShortcutDefinition, platform: ShortcutDisplayPlatform = 'generic', ): string { const parts: string[] = []; if (shortcut.ctrlKey) { parts.push('Ctrl'); } if (shortcut.altKey) { parts.push('Alt'); } if (shortcut.shiftKey && normaliseKeyboardKey(shortcut.key) !== '?') { parts.push('Shift'); } if (shortcut.metaKey) { parts.push(META_LABEL[platform]); } parts.push(keyboardKeyLabel(shortcut.key)); return parts.join(' + '); } export function formatAriaKeyShortcut(shortcut: ShortcutDefinition): string { const parts: string[] = []; if (shortcut.ctrlKey) { parts.push('Control'); } if (shortcut.altKey) { parts.push('Alt'); } if (shortcut.shiftKey) { parts.push('Shift'); } if (shortcut.metaKey) { parts.push('Meta'); } parts.push(ariaKeyName(shortcut.key)); return parts.join('+'); } export function buildAriaKeyShortcuts( shortcuts: readonly ShortcutDefinition[] = DEFAULT_ANIMATION_SHORTCUTS, ): string { return shortcuts .filter((shortcut) => !shortcut.disabled) .map((shortcut) => formatAriaKeyShortcut(shortcut)) .join(' '); } export function buildShortcutHelpRows( shortcuts: readonly ShortcutDefinition[] = DEFAULT_ANIMATION_SHORTCUTS, platform: ShortcutDisplayPlatform = 'generic', ): ShortcutHelpRow[] { return shortcuts .filter((shortcut) => !shortcut.disabled) .map((shortcut, index) => { const chord = formatShortcutChord(shortcut, platform); return { row: { id: shortcut.id, command: shortcut.command, group: shortcut.group, chord, ariaKeyShortcut: formatAriaKeyShortcut(shortcut), description: shortcut.description, ariaLabel: shortcut.ariaLabel ?? `${chord}: ${shortcut.description}`, }, index, }; }) .sort((left, right) => { const groupDelta = GROUP_SORT_ORDER[left.row.group] - GROUP_SORT_ORDER[right.row.group]; return groupDelta === 0 ? left.index - right.index : groupDelta; }) .map(({ row }) => row); } export function createReducedMotionAnimationPlan( input: ReducedMotionAnimationPlanInput, ): ReducedMotionAnimationPlan { const requestedTimeScale = positiveNumber(input.requestedTimeScale, 1); const maxReducedTimeScale = clamp(positiveNumber(input.maxReducedTimeScale, 0.35), 0.05, 1); const reducedRpmMultiplier = clamp(positiveNumber(input.reducedRpmMultiplier, 0.25), 0.05, 1); if (!input.prefersReducedMotion) { return { mode: 'normal', shouldAutoPlay: true, allowContinuousPlayback: true, timeScale: requestedTimeScale, rpmMultiplier: 1, reason: 'No reduced-motion preference is active.', }; } if (input.essentialMotion) { return { mode: 'reduced-continuous', shouldAutoPlay: input.userInitiated === true, allowContinuousPlayback: true, timeScale: Math.min(requestedTimeScale, maxReducedTimeScale), rpmMultiplier: reducedRpmMultiplier, reason: 'Reduced motion is active; essential explanatory motion is capped and never auto-started unless requested by the user.', }; } if (!input.userInitiated) { return { mode: 'static-snapshot', shouldAutoPlay: false, allowContinuousPlayback: false, timeScale: 0, rpmMultiplier: 0, reason: 'Reduced motion is active; non-essential animation should render as a static snapshot until the user asks for motion.', }; } if (input.allowContinuousReducedMotion) { return { mode: 'reduced-continuous', shouldAutoPlay: true, allowContinuousPlayback: true, timeScale: Math.min(requestedTimeScale, maxReducedTimeScale), rpmMultiplier: reducedRpmMultiplier, reason: 'Reduced motion is active; user-initiated continuous playback is allowed with capped speed.', }; } return { mode: 'step-through', shouldAutoPlay: false, allowContinuousPlayback: false, timeScale: 0, rpmMultiplier: 0, reason: 'Reduced motion is active; user-initiated motion should advance through deterministic steps instead of looping continuously.', }; } export function createAnimationStatusAnnouncement( input: AnimationStatusAnnouncementInput, ): string { const machineName = input.machineName?.trim(); const subject = machineName ? `${machineName} animation` : 'Animation'; const playbackState = resolvePlaybackState(input); const sentences: string[] = [`${subject} ${playbackStatePhrase(playbackState)}`]; const details: string[] = []; if (isFiniteNumber(input.rpm)) { details.push(`${formatNumber(Math.max(0, input.rpm), input.rpm >= 100 ? 0 : 1)} RPM`); } if (isFiniteNumber(input.timeScale) && Math.abs(input.timeScale - 1) > 0.01) { details.push(`${formatNumber(Math.max(0, input.timeScale), 2)}× speed`); } if (isFiniteNumber(input.progress)) { details.push(`${formatNumber(normalisePercent(input.progress), 0)}% through cycle`); } if (input.loop === false) { details.push('looping off'); } if (input.stepMode) { details.push('step-through mode'); } if (input.reducedMotion) { details.push('reduced motion'); } if (details.length > 0) { sentences.push(details.join(', ')); } const tourSentence = buildTourAnnouncement(input); if (tourSentence) { sentences.push(tourSentence); } const highlightedPart = input.highlightedPartName?.trim(); if (highlightedPart) { sentences.push(`Highlighted part: ${highlightedPart}`); } return `${sentences.map(stripTrailingPeriod).join('. ')}.`; } export function clampAnimationControlValue( value: number | null | undefined, range: AnimationControlValueRange, ): number { const low = Math.min(range.min, range.max); const high = Math.max(range.min, range.max); const fallback = isFiniteNumber(range.fallback) ? range.fallback : low; const raw = isFiniteNumber(value) ? value : fallback; const clamped = clamp(raw, low, high); if (!isFiniteNumber(range.step) || range.step <= 0) { return roundControlValue(clamped); } const snapped = low + Math.round((clamped - low) / range.step) * range.step; return roundControlValue(clamp(snapped, low, high)); } function modifiersMatch( event: KeyboardShortcutEventLike, shortcut: ShortcutDefinition, ): boolean { return ( Boolean(event.altKey) === Boolean(shortcut.altKey) && Boolean(event.ctrlKey) === Boolean(shortcut.ctrlKey) && Boolean(event.metaKey) === Boolean(shortcut.metaKey) && Boolean(event.shiftKey) === Boolean(shortcut.shiftKey) ); } function normaliseKeyboardCode(code: string | null | undefined): string { if (!code) { return ''; } return code.trim().toLowerCase(); } function ariaKeyName(key: string): string { const normalised = normaliseKeyboardKey(key); switch (normalised) { case 'space': return 'Space'; case 'arrowleft': return 'ArrowLeft'; case 'arrowright': return 'ArrowRight'; case 'arrowup': return 'ArrowUp'; case 'arrowdown': return 'ArrowDown'; case 'escape': return 'Escape'; default: return normalised.length === 1 ? normalised.toUpperCase() : normalised.charAt(0).toUpperCase() + normalised.slice(1); } } function readRecordValue(source: unknown, key: string): unknown { if (!source || typeof source !== 'object' || !(key in source)) { return undefined; } return (source as Record)[key]; } function readAttribute(target: unknown, attributeName: string): string | undefined { const getAttribute = readRecordValue(target, 'getAttribute'); if (typeof getAttribute === 'function') { const value = getAttribute.call(target, attributeName); if (typeof value === 'string') { return value; } if (value != null) { return String(value); } } const propertyValue = readRecordValue(target, attributeName); if (typeof propertyValue === 'string') { return propertyValue; } if (propertyValue != null) { return String(propertyValue); } return undefined; } function resolvePlaybackState( input: AnimationStatusAnnouncementInput, ): AnimationPlaybackAnnouncementState { if (input.playbackState) { return input.playbackState; } if (input.isPlaying === true) { return 'playing'; } if (input.isPlaying === false) { return 'paused'; } return 'idle'; } function playbackStatePhrase(state: AnimationPlaybackAnnouncementState): string { switch (state) { case 'idle': return 'ready'; case 'playing': return 'playing'; case 'paused': return 'paused'; case 'stopped': return 'stopped'; case 'stepping': return 'advanced one step'; case 'ended': return 'finished'; } } function buildTourAnnouncement(input: AnimationStatusAnnouncementInput): string | undefined { const title = input.tourTitle?.trim(); const stepTitle = input.tourStepTitle?.trim(); const currentStep = positiveInteger(input.currentStep); const totalSteps = positiveInteger(input.totalSteps); if (!title && !stepTitle && currentStep == null && totalSteps == null) { return undefined; } const fragments: string[] = []; if (title) { fragments.push(`Tour: ${title}`); } else { fragments.push('Guided tour'); } if (currentStep != null && totalSteps != null) { fragments.push(`step ${Math.min(currentStep, totalSteps)} of ${totalSteps}`); } else if (currentStep != null) { fragments.push(`step ${currentStep}`); } else if (totalSteps != null) { fragments.push(`${totalSteps} steps`); } if (stepTitle) { fragments.push(stepTitle); } return fragments.join(', '); } function positiveInteger(value: number | undefined): number | undefined { if (!isFiniteNumber(value)) { return undefined; } return Math.max(1, Math.trunc(value)); } function normalisePercent(value: number): number { const percent = value <= 1 ? value * 100 : value; return clamp(percent, 0, 100); } function stripTrailingPeriod(value: string): string { return value.replace(/\.+$/u, ''); } function positiveNumber(value: number | undefined, fallback: number): number { return isFiniteNumber(value) && value > 0 ? value : fallback; } function isFiniteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } function roundControlValue(value: number): number { return Number(value.toFixed(6)); } function formatNumber(value: number, maximumFractionDigits: number): string { return new Intl.NumberFormat('en-US', { maximumFractionDigits, }).format(value); }