@lincyaw/workbench 0.0.0-stage → 0.2.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +66 -2
  3. package/package.json +68 -3
  4. package/src/MediaClipEditor.tsx +122 -0
  5. package/src/StepProductionEditor.tsx +130 -0
  6. package/src/approvals.tsx +60 -0
  7. package/src/assistant-mascot.ts +62 -0
  8. package/src/audio-recording-messages.ts +44 -0
  9. package/src/audio-recording.ts +192 -0
  10. package/src/audio-wav.ts +25 -0
  11. package/src/audio-waveform.ts +110 -0
  12. package/src/audition.ts +84 -0
  13. package/src/authoring-client.ts +136 -0
  14. package/src/authoring-draft-fragment.ts +28 -0
  15. package/src/base-controls.tsx +47 -0
  16. package/src/caption-timing-drafts.ts +61 -0
  17. package/src/caption-timing-messages.ts +14 -0
  18. package/src/caption-timing.css +20 -0
  19. package/src/caption-timing.ts +14 -0
  20. package/src/component-draft-actions.ts +54 -0
  21. package/src/component-draft-inspection.ts +35 -0
  22. package/src/component-properties.css +24 -0
  23. package/src/component-properties.ts +45 -0
  24. package/src/component-property-drafts.ts +273 -0
  25. package/src/component-property-messages.ts +31 -0
  26. package/src/components.tsx +631 -0
  27. package/src/context.tsx +27 -0
  28. package/src/diagnostics.ts +113 -0
  29. package/src/document-draft-store.ts +179 -0
  30. package/src/document-drafts.ts +178 -0
  31. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  32. package/src/document-editor/ComponentMaterials.tsx +62 -0
  33. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  34. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  35. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  36. package/src/document-editor/DocumentComments.tsx +282 -0
  37. package/src/document-editor/DocumentEditor.tsx +192 -0
  38. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  39. package/src/document-editor/NarrationEditor.tsx +110 -0
  40. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  41. package/src/document-editor/authoring.tsx +259 -0
  42. package/src/document-editor/block-playback.ts +90 -0
  43. package/src/document-editor/block-preview-messages.ts +13 -0
  44. package/src/document-editor/blocknote-messages.ts +98 -0
  45. package/src/document-editor/clipboard.ts +40 -0
  46. package/src/document-editor/colors.ts +57 -0
  47. package/src/document-editor/comment-draft-actions.ts +58 -0
  48. package/src/document-editor/comment-draft-inspection.ts +38 -0
  49. package/src/document-editor/comment-drafts.ts +361 -0
  50. package/src/document-editor/comments.ts +410 -0
  51. package/src/document-editor/file-input.ts +71 -0
  52. package/src/document-editor/focus.ts +54 -0
  53. package/src/document-editor/inline.ts +104 -0
  54. package/src/document-editor/math.tsx +63 -0
  55. package/src/document-editor/messages.ts +14 -0
  56. package/src/document-editor/narration-schema.tsx +85 -0
  57. package/src/document-editor/narration.ts +37 -0
  58. package/src/document-editor/popover.tsx +107 -0
  59. package/src/document-editor/projection.ts +423 -0
  60. package/src/document-editor/rich-text.ts +152 -0
  61. package/src/document-editor/schema.tsx +117 -0
  62. package/src/document-editor/selection.ts +152 -0
  63. package/src/document-editor/style.css +9 -0
  64. package/src/document-editor/styles.d.ts +1 -0
  65. package/src/document-editor/table-identities.ts +67 -0
  66. package/src/document-preview.ts +112 -0
  67. package/src/document-return-point.ts +59 -0
  68. package/src/document-session.ts +678 -0
  69. package/src/document-timing-drafts.ts +61 -0
  70. package/src/document-voices.ts +2 -0
  71. package/src/history.ts +167 -0
  72. package/src/index.ts +36 -0
  73. package/src/jobs.ts +111 -0
  74. package/src/mascot-preference.ts +37 -0
  75. package/src/media-drafts.ts +83 -0
  76. package/src/media-messages.ts +53 -0
  77. package/src/media-time.ts +12 -0
  78. package/src/messages.ts +781 -0
  79. package/src/outline.ts +97 -0
  80. package/src/panels/ChangeTrail.tsx +79 -0
  81. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  82. package/src/panels/DocumentView.tsx +462 -0
  83. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  84. package/src/panels/DocumentVoiceField.tsx +35 -0
  85. package/src/panels/ExportPanel.tsx +133 -0
  86. package/src/panels/HistoryPanel.tsx +88 -0
  87. package/src/panels/HomePanel.tsx +184 -0
  88. package/src/panels/PageDeleteDialog.tsx +33 -0
  89. package/src/panels/PageRenameDialog.tsx +27 -0
  90. package/src/panels/PreviewPanel.tsx +79 -0
  91. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  92. package/src/panels/ProductionMediaClips.tsx +156 -0
  93. package/src/panels/ProductionPanel.tsx +272 -0
  94. package/src/panels/ProductionRecording.tsx +107 -0
  95. package/src/panels/ProductionRecordingRange.tsx +118 -0
  96. package/src/panels/ProductionTimeline.tsx +62 -0
  97. package/src/panels/ProductionTranscription.tsx +140 -0
  98. package/src/panels/SourceDocumentPanel.tsx +215 -0
  99. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  100. package/src/panels/TranscriptionAdoption.tsx +147 -0
  101. package/src/panels/VoiceCloneDialog.tsx +152 -0
  102. package/src/panels/VoicePreviewDialog.tsx +74 -0
  103. package/src/panels/VoiceScopeField.tsx +30 -0
  104. package/src/panels/VoiceoverDialog.tsx +216 -0
  105. package/src/places.ts +102 -0
  106. package/src/preview-playback.ts +125 -0
  107. package/src/preview-pool.ts +320 -0
  108. package/src/preview.ts +110 -0
  109. package/src/production-messages.ts +151 -0
  110. package/src/production-playback.ts +109 -0
  111. package/src/production-voice-playback.ts +114 -0
  112. package/src/recording-adoption.ts +33 -0
  113. package/src/schema-form/PmSchemaForm.tsx +187 -0
  114. package/src/schema-form/context.ts +26 -0
  115. package/src/schema-form/fields.tsx +59 -0
  116. package/src/schema-form/templates.tsx +50 -0
  117. package/src/schema-form/validator.ts +36 -0
  118. package/src/schema-form/widgets.tsx +60 -0
  119. package/src/shortcuts.ts +8 -0
  120. package/src/sortable-list.tsx +201 -0
  121. package/src/speech-settings-messages.ts +40 -0
  122. package/src/step-production.css +5 -0
  123. package/src/step-production.ts +51 -0
  124. package/src/styles.css +1025 -0
  125. package/src/telemetry.ts +152 -0
  126. package/src/transcription-messages.ts +26 -0
  127. package/src/transcription-session.ts +159 -0
  128. package/src/use-recording-adoption.ts +84 -0
  129. package/src/voice-clone-messages.ts +92 -0
  130. package/src/voice-drafts.ts +89 -0
  131. package/src/voice-messages.ts +146 -0
  132. package/src/voice-snapshot.ts +55 -0
  133. package/src/voiceover-jobs.ts +87 -0
  134. package/src/workbench.ts +2328 -0
  135. package/src/workspace-client.ts +480 -0
@@ -0,0 +1,631 @@
1
+ import type { IconName } from '@lincyaw/design'
2
+ import { mountMascot } from '@lincyaw/design/mascot'
3
+ import type { MascotAction } from '@lincyaw/design/mascot'
4
+ import * as Dialog from '@radix-ui/react-dialog'
5
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
6
+ import * as Popover from '@radix-ui/react-popover'
7
+ import * as Tabs from '@radix-ui/react-tabs'
8
+ import * as Toolbar from '@radix-ui/react-toolbar'
9
+ import * as RadioGroup from '@radix-ui/react-radio-group'
10
+ import { forwardRef, useEffect, useId, useMemo, useRef, useState } from 'react'
11
+ import type { ComponentProps, CSSProperties, InputHTMLAttributes, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react'
12
+ import { Group as ResizableGroup, Panel as ResizablePanel, Separator as ResizableSeparator } from 'react-resizable-panels'
13
+ import { PmButton, PmIcon, PmIconButton, PmTooltip } from './base-controls.tsx'
14
+ import { useMascotEnabled } from './mascot-preference.ts'
15
+ import type { ControlDensity } from './base-controls.tsx'
16
+ export { PmButton, PmIcon, PmIconButton, PmTooltip } from './base-controls.tsx'
17
+ export type { ButtonVariant, ControlDensity } from './base-controls.tsx'
18
+ export { PmSortableList } from './sortable-list.tsx'
19
+ export type { SortableListItem, SortableListLabels, SortableListProps } from './sortable-list.tsx'
20
+
21
+ const cls = (...names: (string | false | undefined | null)[]) => names.filter(Boolean).join(' ')
22
+
23
+ /** The dependency owns pointer capture, iframe shielding, constraints and keyboard resizing. */
24
+ export function PmSplitGroup({ className, orientation = 'horizontal', ...props }: ComponentProps<typeof ResizableGroup>) {
25
+ return <ResizableGroup {...props} orientation={orientation} data-pm-orientation={orientation} className={cls('pm-ui-split-group', className)} />
26
+ }
27
+ export function PmSplitPanel({ className, hidden, background, scroll, style, ...props }: ComponentProps<typeof ResizablePanel> & { background?: boolean, scroll?: boolean }) {
28
+ // Panel assigns className/style to its content and HTML attributes to its
29
+ // sizing element. Mark that outer element explicitly for hiding and retention.
30
+ return <ResizablePanel {...props} hidden={hidden} data-pm-split-panel="" data-pm-background={background || undefined} aria-hidden={background || props['aria-hidden']} {...background ? { inert: '' } : {}}
31
+ style={{ overflow: scroll ? 'auto' : 'hidden', ...style }} className={cls('pm-ui-split-panel', className)} />
32
+ }
33
+ export function PmSplitHandle({ label, className, ...props }: ComponentProps<typeof ResizableSeparator> & { label: string }) {
34
+ return <ResizableSeparator {...props} aria-label={label} className={cls('pm-ui-split-handle', className)} />
35
+ }
36
+
37
+ /** Shared chrome for docked editors; content and actions remain owned by the caller. */
38
+ export function PmPanelHeader({ title, meta, leading, actions }: { title: string, meta?: ReactNode, leading?: ReactNode, actions?: ReactNode }) {
39
+ return <header className="pm-ui-panel-header">
40
+ {leading}<h2 className="pm-ui-panel-header__title">{title}</h2>
41
+ {meta && <span className="pm-ui-panel-header__meta">{meta}</span>}
42
+ {actions && <div className="pm-ui-panel-header__actions">{actions}</div>}
43
+ </header>
44
+ }
45
+
46
+ /** One tab stop, with arrow/Home/End navigation supplied by Radix. */
47
+ export function PmToolbar({ label, children, className, scroll }: { label: string, children: ReactNode, className?: string, scroll?: boolean }) {
48
+ return <Toolbar.Root className={cls(scroll && 'pm-ui-toolbar-scroll', className)} aria-label={label} loop>{children}</Toolbar.Root>
49
+ }
50
+ export const PmToolbarButton = Toolbar.Button
51
+
52
+ export interface Choice { value: string, label: string, icon?: IconName, disabled?: boolean }
53
+ export function PmTabs({ value, tabs, label, iconOnly, density = 'compact', variant = 'default', idPrefix, onValueChange, children }: { value: string, tabs: Choice[], label: string, iconOnly?: boolean, density?: ControlDensity, variant?: 'default' | 'dock', idPrefix?: string, onValueChange(value: string): void, children?: ReactNode }) {
54
+ return <Tabs.Root value={value} onValueChange={onValueChange} activationMode="manual" className="pm-ui-tabs-root" data-density={density} data-variant={variant}>
55
+ <Tabs.List className="pm-ui-tabs" aria-label={label}>{tabs.map(tab => {
56
+ const trigger = <Tabs.Trigger className="pm-ui-tab" value={tab.value} id={idPrefix ? `${idPrefix}-${tab.value}-tab` : undefined} aria-controls={idPrefix ? `${idPrefix}-${tab.value}-panel` : undefined} disabled={tab.disabled} aria-label={tab.label} data-icon-only={iconOnly || undefined}>{tab.icon && <PmIcon name={tab.icon} />}{!iconOnly && tab.label}</Tabs.Trigger>
57
+ return iconOnly ? <PmTooltip key={tab.value} label={tab.label}>{trigger}</PmTooltip> : <span key={tab.value} className="pm-ui-tab-slot">{trigger}</span>
58
+ })}</Tabs.List>{children}
59
+ </Tabs.Root>
60
+ }
61
+ export function PmTabPanel({ value, keepMounted, children }: { value: string, keepMounted?: boolean, children: ReactNode }) {
62
+ return <Tabs.Content value={value} forceMount={keepMounted || undefined} data-keep-mounted={keepMounted || undefined} className="pm-ui-tab-panel">{children}</Tabs.Content>
63
+ }
64
+ export function PmSegmented({ label, value, options, density = 'compact', disabled, onChange }: { label: string, value: string, options: Choice[], density?: ControlDensity, disabled?: boolean, onChange(value: string): void }) {
65
+ return <RadioGroup.Root className="pm-ui-segmented" aria-label={label} value={value} onValueChange={onChange} orientation="horizontal" data-density={density} disabled={disabled}>
66
+ {options.map(option => <RadioGroup.Item key={option.value} className="pm-ui-segmented__option" value={option.value} disabled={option.disabled}>{option.icon && <PmIcon name={option.icon} />}{option.label}</RadioGroup.Item>)}
67
+ </RadioGroup.Root>
68
+ }
69
+
70
+ export interface SearchListItem {
71
+ value: string
72
+ label: string
73
+ /** Non-interactive supporting text; actions belong outside the choice. */
74
+ description?: ReactNode
75
+ keywords?: readonly string[]
76
+ icon?: IconName
77
+ disabled?: boolean
78
+ }
79
+ export interface SearchListProps {
80
+ label: string
81
+ searchLabel: string
82
+ searchPlaceholder?: string
83
+ items: readonly SearchListItem[]
84
+ value?: string
85
+ onValueChange(value: string): void
86
+ /** Explicit activation is separate from moving a candidate with arrow keys. */
87
+ onActivate?(value: string): void
88
+ query?: string
89
+ onQueryChange?(query: string): void
90
+ resultsLabel(count: number): string
91
+ emptyLabel: string
92
+ loadingLabel: string
93
+ loading?: boolean
94
+ error?: ReactNode
95
+ disabled?: boolean
96
+ density?: ControlDensity
97
+ autoFocus?: boolean
98
+ 'aria-describedby'?: string
99
+ }
100
+
101
+ /** Search and a single choice compose with Dialog; Radix owns radio navigation and selection. */
102
+ export function PmSearchList({ label, searchLabel, searchPlaceholder, items, value, onValueChange, onActivate, query, onQueryChange, resultsLabel, emptyLabel, loadingLabel, loading, error, disabled, density = 'compact', autoFocus, 'aria-describedby': describedBy }: SearchListProps) {
103
+ const id = useId()
104
+ const [localQuery, setLocalQuery] = useState('')
105
+ const list = useRef<HTMLDivElement>(null)
106
+ const navigating = useRef(false)
107
+ const navigationRelease = useRef<ReturnType<typeof setTimeout>>()
108
+ const navigationKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End']
109
+ const clearNavigation = () => { if (navigationRelease.current) clearTimeout(navigationRelease.current); navigationRelease.current = undefined; navigating.current = false }
110
+ useEffect(() => () => { if (navigationRelease.current) clearTimeout(navigationRelease.current) }, [])
111
+ const search = query ?? localQuery
112
+ const matches = useMemo(() => {
113
+ const terms = search.normalize('NFKC').toLowerCase().trim().split(/\s+/).filter(Boolean)
114
+ return items.filter(item => {
115
+ const text = [item.label, ...item.keywords ?? []].join(' ').normalize('NFKC').toLowerCase()
116
+ return terms.every(term => text.includes(term))
117
+ })
118
+ }, [items, search])
119
+ const blocked = Boolean(disabled || loading || error)
120
+ const errorId = error ? `${id}-error` : undefined
121
+ return <div className="pm-ui-search-list" data-density={density} onKeyDownCapture={event => {
122
+ if (navigationKeys.includes(event.key)) { clearNavigation(); navigating.current = true }
123
+ else if (event.key === 'Enter' || event.key === ' ') clearNavigation()
124
+ }} onKeyUpCapture={event => {
125
+ // Radix can defer arrow-key focus and its corresponding synthetic click until the next task.
126
+ if (navigationKeys.includes(event.key)) {
127
+ if (navigationRelease.current) clearTimeout(navigationRelease.current)
128
+ navigationRelease.current = setTimeout(() => { navigating.current = false; navigationRelease.current = undefined }, 0)
129
+ }
130
+ }} onPointerDownCapture={clearNavigation} onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget)) clearNavigation() }}>
131
+ <PmInput label={searchLabel} value={search} placeholder={searchPlaceholder} prefixIcon="search" density={density} disabled={disabled} autoFocus={autoFocus} aria-controls={`${id}-results`} onChange={event => {
132
+ if (query === undefined) setLocalQuery(event.target.value)
133
+ onQueryChange?.(event.target.value)
134
+ }} onKeyDown={event => {
135
+ if (event.key !== 'ArrowDown' || event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return
136
+ const selected = list.current?.querySelector<HTMLElement>('[role="radio"][data-state="checked"]:not(:disabled)')
137
+ const first = selected ?? list.current?.querySelector<HTMLElement>('[role="radio"]:not(:disabled)')
138
+ if (first) { event.preventDefault(); first.focus() }
139
+ }} />
140
+ <div className="pm-ui-search-list__heading"><span id={`${id}-label`}>{label}</span><span className="pm-ui-search-list__count" role="status" aria-live="polite" aria-atomic="true">{loading && <span className="pm-ui-loading" aria-hidden="true"><PmIcon name="loading" /></span>}{loading ? loadingLabel : resultsLabel(matches.length)}</span></div>
141
+ {error && <p id={errorId} className="pm-ui-field__error" role="alert">{error}</p>}
142
+ <RadioGroup.Root ref={list} id={`${id}-results`} className="pm-ui-search-list__results" aria-labelledby={`${id}-label`} aria-describedby={[describedBy, errorId].filter(Boolean).join(' ') || undefined} aria-invalid={error ? true : undefined} aria-busy={loading || undefined} value={value ?? ''} disabled={blocked} orientation="vertical" loop onValueChange={next => { if (!blocked) onValueChange(next) }}>
143
+ {matches.map((item, index) => <RadioGroup.Item key={item.value} value={item.value} disabled={item.disabled} className="pm-ui-search-list__option" aria-labelledby={`${id}-option-${index}`} aria-describedby={item.description ? `${id}-description-${index}` : undefined} onClick={() => { if (!blocked && !item.disabled && !navigating.current) onActivate?.(item.value) }} onKeyDown={event => { if (event.key === 'Enter' && onActivate && !blocked && !item.disabled) { event.preventDefault(); onActivate(item.value) } }}>
144
+ {item.icon && <PmIcon name={item.icon} />}
145
+ <span className="pm-ui-search-list__text"><span id={`${id}-option-${index}`} className="pm-ui-search-list__label" title={item.label}>{item.label}</span>{item.description && <span id={`${id}-description-${index}`} className="pm-ui-search-list__description">{item.description}</span>}</span>
146
+ <span className="pm-ui-search-list__check" aria-hidden="true">{item.value === value && <PmIcon name="check" />}</span>
147
+ </RadioGroup.Item>)}
148
+ {!matches.length && !loading && !error && <p className="pm-ui-search-list__empty">{emptyLabel}</p>}
149
+ </RadioGroup.Root>
150
+ </div>
151
+ }
152
+
153
+ export type Tone = 'neutral' | 'accent' | 'ok' | 'warn' | 'danger' | 'outline'
154
+
155
+ export function PmBadge({ tone = 'neutral', pill, children }: { tone?: Tone, pill?: boolean, children?: ReactNode }) {
156
+ return <span className={cls('pm-ui-badge', tone !== 'neutral' && `pm-ui-badge--${tone}`, pill && 'pm-ui-badge--pill')}>{children}</span>
157
+ }
158
+
159
+ export function PmCard({ variant = 'default', className, children, ...rest }: { variant?: 'default' | 'interactive' | 'dashed', className?: string, children?: ReactNode, [attribute: `data-${string}`]: string | undefined }) {
160
+ return <div className={cls('pm-ui-card', variant !== 'default' && `pm-ui-card--${variant}`, className)} {...rest}>{children}</div>
161
+ }
162
+
163
+ export function PmField({ label, labelHidden, inline, group, hint, error, htmlFor, hintId, errorId, density, children }: { label: string, labelHidden?: boolean, inline?: boolean, group?: boolean, hint?: ReactNode, error?: ReactNode, htmlFor?: string, hintId?: string, errorId?: string, density?: ControlDensity, children?: ReactNode }) {
164
+ const feedback = <>{hint && <span id={hintId} className="pm-ui-field__hint">{hint}</span>}{error && <span id={errorId} className="pm-ui-field__error" role="alert">{error}</span>}</>
165
+ if (group) return <div className="pm-ui-field" data-density={density} role="group" aria-label={label}><span className="pm-ui-field__label">{label}</span>{children}{feedback}</div>
166
+ return (
167
+ <label className={cls('pm-ui-field', inline && 'pm-ui-field--inline')} data-density={density} htmlFor={htmlFor}>
168
+ {inline ? <>{children}<span id={htmlFor ? `${htmlFor}-label` : undefined} className="pm-ui-field__label">{label}</span></> : <><span id={htmlFor ? `${htmlFor}-label` : undefined} className={labelHidden ? 'pm-ui-sr-only' : 'pm-ui-field__label'}>{label}</span>{children}</>}
169
+ {feedback}
170
+ </label>
171
+ )
172
+ }
173
+
174
+ type FieldProps = { label: string, hint?: ReactNode, error?: ReactNode, density?: ControlDensity }
175
+ function useField({ id, hint, error, 'aria-describedby': describedBy }: { id?: string, hint?: ReactNode, error?: ReactNode, 'aria-describedby'?: string }) {
176
+ const generated = useId()
177
+ const controlId = id ?? generated
178
+ return { id: controlId, hintId: hint ? `${controlId}-hint` : undefined, errorId: error ? `${controlId}-error` : undefined,
179
+ describedBy: [describedBy, hint && `${controlId}-hint`, error && `${controlId}-error`].filter(Boolean).join(' ') || undefined }
180
+ }
181
+ export const PmInput = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement> & FieldProps & { labelHidden?: boolean, prefixIcon?: IconName, suffix?: ReactNode, width?: 'number' | 'time' }>(function PmInput({ label, labelHidden, hint, error, density = 'compact', prefixIcon, suffix, width, className, ...rest }, ref) {
182
+ const field = useField({ ...rest, hint, error })
183
+ const input = <input {...rest} ref={ref} id={field.id} data-density={density} data-width={width} aria-labelledby={rest['aria-labelledby'] ?? (rest['aria-label'] ? undefined : `${field.id}-label`)} aria-invalid={error ? true : rest['aria-invalid']} aria-describedby={field.describedBy} className={cls('pm-ui-input', className)} />
184
+ return <PmField label={label} labelHidden={labelHidden} hint={hint} error={error} htmlFor={field.id} hintId={field.hintId} errorId={field.errorId} density={density}>
185
+ {prefixIcon || suffix ? <span className="pm-ui-control" data-width={width} data-density={density}>{prefixIcon && <span className="pm-ui-control__prefix"><PmIcon name={prefixIcon} /></span>}{input}{suffix && <span className="pm-ui-control__suffix">{suffix}</span>}</span> : input}
186
+ </PmField>
187
+ })
188
+ export const PmTextarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement> & FieldProps & { autoGrow?: boolean }>(function PmTextarea({ label, hint, error, density = 'compact', autoGrow, className, ...rest }, ref) {
189
+ const field = useField({ ...rest, hint, error })
190
+ const node = useRef<HTMLTextAreaElement | null>(null)
191
+ useEffect(() => {
192
+ if (!autoGrow || !node.current) return
193
+ node.current.style.height = 'auto'
194
+ node.current.style.height = `${node.current.scrollHeight}px`
195
+ }, [autoGrow, rest.value])
196
+ return <PmField label={label} hint={hint} error={error} htmlFor={field.id} hintId={field.hintId} errorId={field.errorId} density={density}>
197
+ <textarea {...rest} ref={element => { node.current = element; if (typeof ref === 'function') ref(element); else if (ref) ref.current = element }} id={field.id} data-density={density} aria-labelledby={rest['aria-labelledby'] ?? (rest['aria-label'] ? undefined : `${field.id}-label`)} aria-invalid={error ? true : rest['aria-invalid']} aria-describedby={field.describedBy} className={cls('pm-ui-textarea', className)} />
198
+ </PmField>
199
+ })
200
+ export const PmSelect = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement> & FieldProps & { labelHidden?: boolean }>(function PmSelect({ label, labelHidden, hint, error, density = 'compact', className, children, ...rest }, ref) {
201
+ const field = useField({ ...rest, hint, error })
202
+ return <PmField label={label} labelHidden={labelHidden} hint={hint} error={error} htmlFor={field.id} hintId={field.hintId} errorId={field.errorId} density={density}>
203
+ <select {...rest} ref={ref} id={field.id} data-density={density} aria-labelledby={rest['aria-labelledby'] ?? (rest['aria-label'] ? undefined : `${field.id}-label`)} aria-invalid={error ? true : rest['aria-invalid']} aria-describedby={field.describedBy} className={cls('pm-ui-select', className)}>{children}</select>
204
+ </PmField>
205
+ })
206
+
207
+ export interface ChoiceSelectItem extends Choice { description?: ReactNode }
208
+ export interface ChoiceSelectProps {
209
+ label: string
210
+ value: string
211
+ choices: readonly ChoiceSelectItem[]
212
+ onValueChange(value: string): void
213
+ placeholder?: string
214
+ hint?: ReactNode
215
+ error?: ReactNode
216
+ disabled?: boolean
217
+ required?: boolean
218
+ requiredMessage?: string
219
+ name?: string
220
+ id?: string
221
+ density?: ControlDensity
222
+ 'aria-describedby'?: string
223
+ }
224
+
225
+ /** A consistent choice menu; Radix owns typeahead, navigation, dismissal and focus return. */
226
+ export function PmChoiceSelect({ label, value, choices, onValueChange, placeholder, hint, error, disabled, required, requiredMessage, name, id, density = 'regular', 'aria-describedby': describedBy }: ChoiceSelectProps) {
227
+ const [invalid, setInvalid] = useState(false)
228
+ const trigger = useRef<HTMLButtonElement>(null)
229
+ const feedback = error ?? (invalid ? requiredMessage : undefined)
230
+ const field = useField({ id, hint, error: feedback, 'aria-describedby': describedBy })
231
+ const selected = choices.find(choice => choice.value === value)
232
+ useEffect(() => { setInvalid(false) }, [value])
233
+ const change = (next: string) => { setInvalid(false); onValueChange(next) }
234
+ return <PmField label={required ? `${label} *` : label} htmlFor={field.id} hint={hint} error={feedback} hintId={field.hintId} errorId={field.errorId} density={density}>
235
+ <DropdownMenu.Root>
236
+ <DropdownMenu.Trigger asChild><button ref={trigger} id={field.id} type="button" className="pm-ui-choice-select__trigger" data-density={density} data-placeholder={!selected || undefined} disabled={disabled} aria-labelledby={`${field.id}-label ${field.id}-value`} aria-describedby={field.describedBy} aria-invalid={Boolean(error || invalid) || undefined}>
237
+ {selected?.icon && <PmIcon name={selected.icon} />}<span id={`${field.id}-value`} className="pm-ui-choice-select__value">{selected?.label ?? placeholder ?? label}</span><PmIcon name="chevron-down" />
238
+ </button></DropdownMenu.Trigger>
239
+ <DropdownMenu.Portal><DropdownMenu.Content className="pm-ui-menu pm-ui-floating pm-ui pm-ui-choice-select__menu" data-density={density} aria-label={label} side="bottom" align="start" sideOffset={6} collisionPadding={16} onEscapeKeyDown={event => event.stopPropagation()}>
240
+ <DropdownMenu.RadioGroup value={value} onValueChange={change}>
241
+ {choices.filter(choice => choice.value !== '').map(choice => <DropdownMenu.RadioItem key={choice.value} value={choice.value} textValue={choice.label} disabled={choice.disabled} className="pm-ui-menu__item pm-ui-choice-select__option">
242
+ {choice.icon && <PmIcon name={choice.icon} />}<span className="pm-ui-choice-select__text"><span>{choice.label}</span>{choice.description && <span className="pm-ui-choice-select__description">{choice.description}</span>}</span><span className="pm-ui-choice-select__check"><DropdownMenu.ItemIndicator><PmIcon name="check" /></DropdownMenu.ItemIndicator></span>
243
+ </DropdownMenu.RadioItem>)}
244
+ </DropdownMenu.RadioGroup>
245
+ </DropdownMenu.Content></DropdownMenu.Portal>
246
+ </DropdownMenu.Root>
247
+ {/* The hidden native proxy participates only in HTML form submission and required validation. */}
248
+ {(name || required) && <select className="pm-ui-choice-select__native" aria-hidden="true" tabIndex={-1} name={name} value={selected?.value ?? ''} required={required} disabled={disabled} onChange={event => change(event.target.value)} onInvalid={event => { event.preventDefault(); setInvalid(true); trigger.current?.focus() }}><option value="" />{choices.filter(choice => choice.value !== '').map(choice => <option key={choice.value} value={choice.value} disabled={choice.disabled}>{choice.label}</option>)}</select>}
249
+ </PmField>
250
+ }
251
+
252
+ export interface WorkspaceSwitcherProps {
253
+ label: string
254
+ value?: string
255
+ items: readonly SearchListItem[]
256
+ onValueChange(value: string): void
257
+ placeholder: string
258
+ searchLabel: string
259
+ searchPlaceholder?: string
260
+ emptyLabel: string
261
+ resultsLabel(count: number): string
262
+ loadingLabel: string
263
+ loading?: boolean
264
+ disabled?: boolean
265
+ createLabel: string
266
+ onCreate(): void
267
+ }
268
+
269
+ /** Search candidate choices without navigating; activate by click, Space or Enter to switch. */
270
+ export function PmWorkspaceSwitcher({ label, value, items, onValueChange, placeholder, searchLabel, searchPlaceholder, emptyLabel, resultsLabel, loadingLabel, loading, disabled, createLabel, onCreate }: WorkspaceSwitcherProps) {
271
+ const [open, setOpen] = useState(false)
272
+ const [candidate, setCandidate] = useState(value)
273
+ const panel = useRef<HTMLDivElement>(null)
274
+ const trigger = useRef<HTMLButtonElement>(null)
275
+ const creating = useRef(false)
276
+ const selected = items.find(item => item.value === value)
277
+ return <Popover.Root open={open} onOpenChange={next => { setOpen(next); if (next) { setCandidate(value); creating.current = false } }}>
278
+ <Popover.Trigger asChild><button ref={trigger} type="button" className="pm-ui-workspace-switcher" disabled={disabled || loading} aria-label={`${label}: ${selected?.label ?? placeholder}`} aria-busy={loading || undefined}>
279
+ <span className="pm-ui-workspace-switcher__mark" aria-hidden="true">{loading ? <span className="pm-ui-loading"><PmIcon name="loading" /></span> : selected ? Array.from(selected.label)[0] : <PmIcon name="folder" />}</span>
280
+ <span className="pm-ui-workspace-switcher__text"><span className="pm-ui-workspace-switcher__name">{selected?.label ?? placeholder}</span>{selected?.description && <span className="pm-ui-workspace-switcher__description">{selected.description}</span>}</span><PmIcon name="chevron-down" />
281
+ </button></Popover.Trigger>
282
+ <Popover.Portal><Popover.Content ref={panel} className="pm-ui-popover pm-ui-floating pm-ui pm-ui-workspace-switcher__panel" side="bottom" align="start" sideOffset={6} collisionPadding={16} aria-label={label} onOpenAutoFocus={event => { event.preventDefault(); panel.current?.querySelector('input')?.focus() }} onCloseAutoFocus={event => { if (creating.current) event.preventDefault() }} onEscapeKeyDown={event => event.stopPropagation()}>
283
+ <PmSearchList label={label} searchLabel={searchLabel} searchPlaceholder={searchPlaceholder} items={items} value={candidate} onValueChange={setCandidate} onActivate={next => { setOpen(false); onValueChange(next) }} resultsLabel={resultsLabel} emptyLabel={emptyLabel} loadingLabel={loadingLabel} loading={loading} autoFocus />
284
+ <div className="pm-ui-workspace-switcher__footer"><PmButton variant="ghost" icon="plus" onClick={() => { creating.current = true; setOpen(false); trigger.current?.focus(); onCreate() }}>{createLabel}</PmButton></div>
285
+ </Popover.Content></Popover.Portal>
286
+ </Popover.Root>
287
+ }
288
+
289
+ export type PopoverPlacement = 'below-end' | 'below-start' | 'above-end' | 'above-start'
290
+ const placementOf = (placement: PopoverPlacement) => ({ side: placement.startsWith('above') ? 'top' as const : 'bottom' as const, align: placement.endsWith('start') ? 'start' as const : 'end' as const })
291
+
292
+ export function PmDialog({ open, label, closeLabel, name, actions, description, footer, busy, onClose, onOpenAutoFocus, onCloseAutoFocus, children }: { open: boolean, label: string, closeLabel: string, name?: string, actions?: ReactNode, description?: string, footer?: ReactNode, busy?: boolean, onClose(): void, onOpenAutoFocus?(event: Event): void, onCloseAutoFocus?(event: Event): void, children?: ReactNode }) {
293
+ const id = useId()
294
+ // A descendant's React autoFocus runs before Radix's open-focus event.
295
+ const trigger = useRef<HTMLElement | null>(typeof document === 'undefined' ? null : document.activeElement as HTMLElement)
296
+ useEffect(() => {
297
+ if (open) return
298
+ const remember = (event: FocusEvent) => {
299
+ const element = event.target
300
+ if (element instanceof HTMLElement && element !== document.body && element.closest('[data-pm-dialog]')?.getAttribute('data-pm-dialog') !== id) trigger.current = element
301
+ }
302
+ document.addEventListener('focusin', remember, true)
303
+ return () => document.removeEventListener('focusin', remember, true)
304
+ }, [id, open])
305
+ return (
306
+ <Dialog.Root open={open} modal onOpenChange={next => { if (!next && !busy) onClose() }}>
307
+ <Dialog.Portal>
308
+ <Dialog.Overlay className="pm-ui-dialog-overlay" />
309
+ <Dialog.Content className={cls('pm-ui-dialog', 'pm-ui', name && `${name}__panel`)} data-pm-dialog={id} data-density="regular" aria-modal="true" aria-busy={busy || undefined} {...description ? {} : { 'aria-describedby': undefined }} onInteractOutside={event => event.preventDefault()} onEscapeKeyDown={event => { event.stopPropagation(); if (busy) event.preventDefault() }} onOpenAutoFocus={event => {
310
+ onOpenAutoFocus?.(event)
311
+ if (event.defaultPrevented || !(event.target instanceof HTMLElement)) return
312
+ const panel = event.target
313
+ if (panel.contains(document.activeElement)) { event.preventDefault(); return }
314
+ const target = [...panel.querySelectorAll<HTMLElement>('.pm-ui-dialog__body :is(button, input, textarea, select, a[href], [tabindex]), .pm-ui-dialog__footer :is(button, input, textarea, select, a[href], [tabindex])')].find(element => element.tabIndex >= 0 && !element.closest('[inert], [hidden]') && !element.matches(':disabled, [aria-disabled="true"]') && element.getClientRects().length && getComputedStyle(element).visibility === 'visible')
315
+ if (target) { event.preventDefault(); target.focus() }
316
+ }} onCloseAutoFocus={event => {
317
+ onCloseAutoFocus?.(event)
318
+ if (event.defaultPrevented) return
319
+ event.preventDefault()
320
+ const available = (element: HTMLElement | null): element is HTMLElement => Boolean(element?.isConnected && element !== document.body && !element.closest(`[data-pm-dialog="${id}"], [inert], [hidden]`) && !element.matches(':disabled, [aria-disabled="true"]') && element.getClientRects().length && getComputedStyle(element).visibility === 'visible')
321
+ const target = available(trigger.current) ? trigger.current : [...document.querySelectorAll<HTMLElement>('[role="toolbar"] button, main button')].find(available)
322
+ target?.focus()
323
+ }}>
324
+ <div className="pm-ui-dialog__header">
325
+ <Dialog.Title className="pm-ui-dialog__title">{label}</Dialog.Title>
326
+ {actions}
327
+ <Dialog.Close asChild><PmIconButton icon="close" label={closeLabel} small disabled={busy} /></Dialog.Close>
328
+ </div>
329
+ {description && <Dialog.Description className="pm-ui-dialog__description">{description}</Dialog.Description>}
330
+ <div className="pm-ui-dialog__body">{children}</div>
331
+ {footer && <div className="pm-ui-dialog__footer">{footer}</div>}
332
+ </Dialog.Content>
333
+ </Dialog.Portal>
334
+ </Dialog.Root>
335
+ )
336
+ }
337
+
338
+ /** A non-modal floating panel on its trigger, with the shared `pm-ui-popover` surface (host's PmPopover for React pages). */
339
+ export function PmPopover({ open, label, name, placement = 'below-end', trigger, onOpenChange, children }: { open: boolean, label: string, name?: string, placement?: PopoverPlacement, trigger: ReactNode, onOpenChange(open: boolean): void, children?: ReactNode }) {
340
+ return (
341
+ <Popover.Root open={open} onOpenChange={onOpenChange}>
342
+ <Popover.Trigger asChild>{trigger}</Popover.Trigger>
343
+ <Popover.Portal>
344
+ <Popover.Content className={cls('pm-ui-popover', 'pm-ui-floating', 'pm-ui', name)} aria-label={label} {...placementOf(placement)} sideOffset={6} collisionPadding={16} onEscapeKeyDown={event => event.stopPropagation()}>
345
+ {children}
346
+ </Popover.Content>
347
+ </Popover.Portal>
348
+ </Popover.Root>
349
+ )
350
+ }
351
+
352
+ export interface MenuItem {
353
+ id: string
354
+ label: string
355
+ icon?: IconName
356
+ danger?: boolean
357
+ disabled?: boolean
358
+ shortcut?: string
359
+ checked?: boolean
360
+ separator?: boolean
361
+ }
362
+
363
+ /** `toolbarItem`: the trigger joins the surrounding `PmToolbar`'s arrow-key navigation, e.g. as its overflow ("More") menu. */
364
+ export function PmMenu({ label, description, items, icon = 'more', placement = 'below-end', triggerRef, toolbarItem, onSelect }: { label: string, description?: string, items: MenuItem[], icon?: IconName, placement?: PopoverPlacement, triggerRef?: Ref<HTMLButtonElement>, toolbarItem?: boolean, onSelect(id: string): void }) {
365
+ const descriptionId = useId()
366
+ const trigger = <PmIconButton ref={triggerRef} icon={icon} label={label} small />
367
+ return (
368
+ <DropdownMenu.Root>
369
+ <DropdownMenu.Trigger asChild>{toolbarItem ? <Toolbar.Button asChild>{trigger}</Toolbar.Button> : trigger}</DropdownMenu.Trigger>
370
+ <DropdownMenu.Portal>
371
+ <DropdownMenu.Content className="pm-ui-menu pm-ui-floating pm-ui" data-density="compact" data-description={description ? '' : undefined} aria-label={label} aria-describedby={description ? descriptionId : undefined} {...placementOf(placement)} sideOffset={6} collisionPadding={16} onEscapeKeyDown={event => event.stopPropagation()}>
372
+ {description && <p className="pm-ui-menu__description" id={descriptionId}>{description}</p>}
373
+ {items.map(item => (
374
+ <DropdownMenu.Group key={item.id}>{item.separator && <DropdownMenu.Separator className="pm-ui-menu__separator" />}{item.checked === undefined ? <DropdownMenu.Item textValue={item.label} className={cls('pm-ui-menu__item', item.danger && 'pm-ui-menu__item--danger')} disabled={item.disabled} onSelect={() => onSelect(item.id)}>
375
+ {item.icon && <PmIcon name={item.icon} />}{item.label}{item.shortcut && <kbd className="pm-ui-shortcut">{item.shortcut}</kbd>}
376
+ </DropdownMenu.Item> : <DropdownMenu.CheckboxItem className="pm-ui-menu__item" checked={item.checked} disabled={item.disabled} textValue={item.label} onSelect={() => onSelect(item.id)}><DropdownMenu.ItemIndicator><PmIcon name="check" /></DropdownMenu.ItemIndicator>{item.label}</DropdownMenu.CheckboxItem>}</DropdownMenu.Group>
377
+ ))}
378
+ </DropdownMenu.Content>
379
+ </DropdownMenu.Portal>
380
+ </DropdownMenu.Root>
381
+ )
382
+ }
383
+
384
+ export function PmEmpty({ title, icon, action, children }: { title?: string, icon?: IconName, action?: ReactNode, children?: ReactNode }) {
385
+ return (
386
+ <div className="pm-ui-empty">
387
+ {icon && <PmIcon name={icon} size={28} />}
388
+ {title && <p className="pm-ui-empty__title">{title}</p>}
389
+ {children && <p className="pm-ui-empty__body">{children}</p>}
390
+ {action}
391
+ </div>
392
+ )
393
+ }
394
+
395
+ /** Loading in the shape of the content: `document` is a page of headings and paragraphs, `list` a column of rows and
396
+ * `preview` a slide-shaped area. The label is the status read to assistive technology. */
397
+ export function PmSkeleton({ label, shape = 'document' }: { label: string, shape?: 'document' | 'list' | 'preview' }) {
398
+ const bar = (kind?: 'heading' | 'row' | 'media', key?: number) => <span key={key} className="pm-ui-skeleton__bar" data-kind={kind} />
399
+ const paragraph = (lines: number, key: number) => <span key={key} className="pm-ui-skeleton__group">{Array.from({ length: lines }, (_, index) => bar(undefined, index))}</span>
400
+ return (
401
+ <div className="pm-ui-skeleton" data-shape={shape} role="status" aria-busy="true">
402
+ <span className="pm-ui-sr-only">{label}</span>
403
+ <span className="pm-ui-skeleton" aria-hidden="true">
404
+ {shape === 'list' ? Array.from({ length: 5 }, (_, index) => bar('row', index))
405
+ : shape === 'preview' ? <>{bar('media')}{paragraph(2, 0)}</>
406
+ : <>{bar('heading')}{paragraph(4, 0)}{paragraph(3, 1)}{bar('heading')}{paragraph(4, 2)}</>}
407
+ </span>
408
+ </div>
409
+ )
410
+ }
411
+
412
+ export function PmNotice({ tone = 'neutral', children }: { tone?: 'neutral' | 'accent' | 'ok' | 'warn' | 'danger', children?: ReactNode }) {
413
+ return <div className={cls('pm-ui-notice', tone !== 'neutral' && `pm-ui-notice--${tone}`)} role={tone === 'danger' ? 'alert' : 'status'}>{children}</div>
414
+ }
415
+
416
+ /** The Presmium kitten acting out what the assistant or a long task is doing. It is decoration: the text beside it
417
+ * says the same thing for everyone, so it is hidden from assistive technology. Nothing shows when the author turned
418
+ * the kitten off in Settings. */
419
+ export function PmMascot(props: { action: MascotAction, size?: 'small' | 'regular' }) {
420
+ return useMascotEnabled() ? <MascotPlayer {...props} /> : null
421
+ }
422
+
423
+ function MascotPlayer({ action, size = 'regular' }: { action: MascotAction, size?: 'small' | 'regular' }) {
424
+ const ref = useRef<HTMLSpanElement>(null)
425
+ const player = useRef<ReturnType<typeof mountMascot>>(undefined)
426
+ const first = useRef(action)
427
+ useEffect(() => {
428
+ const mounted = mountMascot(ref.current!, first.current)
429
+ player.current = mounted
430
+ return () => mounted.stop()
431
+ }, [])
432
+ useEffect(() => { player.current?.set(action) }, [action])
433
+ return <span ref={ref} className={cls('pm-ui-mascot', size === 'small' && 'pm-ui-mascot--sm')} data-pm-mascot={action} aria-hidden="true" />
434
+ }
435
+
436
+ export interface RangeProps {
437
+ label: string
438
+ value: number
439
+ min?: number
440
+ max: number
441
+ step?: number
442
+ disabled?: boolean
443
+ density?: ControlDensity
444
+ valueText?(value: number): string
445
+ onValueCommit(value: number): void
446
+ onPreviewChange?(value: number | undefined): void
447
+ }
448
+
449
+ /** Native range owns pointer/keyboard mechanics; only a committed gesture requests adoption. */
450
+ export function PmRange({ label, value, min = 0, max, step = 1, disabled, density = 'compact', valueText, onValueCommit, onPreviewChange }: RangeProps) {
451
+ const [draft, setDraft] = useState<number>()
452
+ const pointer = useRef<'active' | 'cancelled'>(), latest = useRef<number>(), preview = useRef(onPreviewChange)
453
+ preview.current = onPreviewChange
454
+ const blocked = Boolean(disabled || !Number.isFinite(min) || !Number.isFinite(max) || max <= min)
455
+ const minimum = Number.isFinite(min) ? min : 0, maximum = Number.isFinite(max) ? Math.max(minimum, max) : minimum
456
+ const bounded = (next: number) => Math.max(minimum, Math.min(maximum, Number.isFinite(next) ? next : minimum))
457
+ const current = bounded(draft ?? value), progress = max > min ? (current - min) / (max - min) * 100 : 0
458
+ const cancel = () => { if (pointer.current) pointer.current = 'cancelled'; latest.current = undefined; setDraft(undefined); preview.current?.(undefined) }
459
+ useEffect(() => { cancel() }, [blocked, min, max])
460
+ useEffect(() => () => { pointer.current = undefined; latest.current = undefined; preview.current?.(undefined) }, [])
461
+ return <input type="range" className="pm-ui-range" data-density={density} aria-label={label} aria-valuetext={valueText?.(current)} min={minimum} max={maximum} step={step} value={current} disabled={blocked}
462
+ style={{ '--pm-range-progress': `${progress}%` } as CSSProperties}
463
+ onPointerDown={() => { if (!blocked) pointer.current = 'active' }}
464
+ onPointerCancel={() => { cancel(); pointer.current = undefined }} onBlur={cancel}
465
+ onPointerUp={() => { const active = pointer.current === 'active', next = latest.current; cancel(); pointer.current = undefined; if (active && !blocked && next !== undefined) onValueCommit(next) }}
466
+ onKeyDown={event => { if (event.key === 'Escape') { event.preventDefault(); cancel() } }}
467
+ onChange={event => {
468
+ if (blocked || pointer.current === 'cancelled') return
469
+ const next = bounded(event.currentTarget.valueAsNumber)
470
+ if (pointer.current === 'active') { latest.current = next; setDraft(next); preview.current?.(next) }
471
+ else onValueCommit(next)
472
+ }} />
473
+ }
474
+
475
+ export type TimelineTiming = 'confirmed' | 'estimated' | 'unknown'
476
+ export interface TimelineSegment {
477
+ id: string
478
+ stepId?: string
479
+ label: string
480
+ startMs?: number
481
+ endMs?: number
482
+ timing: TimelineTiming
483
+ statusLabel?: string
484
+ disabled?: boolean
485
+ /** Real normalized peak amplitudes; absence never produces a decorative waveform. */
486
+ waveform?: readonly number[]
487
+ }
488
+ export interface TimelineTrack { id: 'visual' | 'voice' | 'caption', label: string, segments: readonly TimelineSegment[] }
489
+ export interface TimelineLabels {
490
+ position: string
491
+ seconds: string
492
+ seek: string
493
+ zoomIn: string
494
+ zoomOut: string
495
+ fit: string
496
+ estimated: string
497
+ unknown: string
498
+ loading: string
499
+ empty: string
500
+ seekHint: string
501
+ invalidPosition: string
502
+ }
503
+ export interface TimelineProps {
504
+ label: string
505
+ tracks: readonly TimelineTrack[]
506
+ labels: TimelineLabels
507
+ durationMs?: number
508
+ durationStatus?: TimelineTiming
509
+ currentTimeMs?: number
510
+ selectedStepId?: string
511
+ zoom?: number
512
+ onZoomChange?(zoom: number): void
513
+ onSelectStep(stepId: string): void
514
+ /** Optional segment activation; the caller owns whether its confirmed time may be sought. */
515
+ onSelectSegment?(track: TimelineTrack['id'], segment: TimelineSegment): void
516
+ /** Full-size narrow/coarse alternative to short proportional segments; no timing is inferred here. */
517
+ segmentPicker?: { track: TimelineTrack['id'], ids: readonly string[], label: string, placeholder: string }
518
+ /** Caller pauses and confirms its source/clock before updating controlled currentTimeMs. */
519
+ onSeek(timeMs: number): void
520
+ seekDisabled?: boolean
521
+ disabled?: boolean
522
+ loading?: boolean
523
+ seeking?: boolean
524
+ density?: ControlDensity
525
+ /** Omit the fallback step strip when the surrounding editor already provides step navigation. */
526
+ showSteps?: boolean
527
+ /** A flush docked editor, with status and zoom in the same tool row. */
528
+ variant?: 'default' | 'editor'
529
+ }
530
+
531
+ const timelineTime = (ms: number) => `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${(ms % 60000 / 1000).toFixed(3).padStart(6, '0')}`
532
+ function waveformPath(samples: readonly number[] | undefined): string | undefined {
533
+ if (!samples?.length || !samples.every(value => Number.isFinite(value) && value >= 0 && value <= 1)) return undefined
534
+ const length = Math.min(160, samples.length)
535
+ return Array.from({ length }, (_, index) => {
536
+ let peak = 0
537
+ for (let at = Math.floor(index * samples.length / length); at < Math.floor((index + 1) * samples.length / length); at++) peak = Math.max(peak, samples[at]!)
538
+ return `M${(index + 0.5) / length * 160},${10 - peak * 10}V${10 + peak * 10}`
539
+ }).join(' ')
540
+ }
541
+
542
+ /** Data-only timeline; source, playback, seeking authority and all labels remain with its caller. */
543
+ export function PmTimeline({ label, tracks, labels, durationMs, durationStatus, currentTimeMs, selectedStepId, zoom, onZoomChange, onSelectStep, onSelectSegment, segmentPicker, onSeek, seekDisabled, disabled, loading, seeking, density = 'compact', showSteps = true, variant = 'default' }: TimelineProps) {
544
+ const hintId = useId()
545
+ const viewport = useRef<HTMLDivElement>(null), tick = useRef<HTMLSpanElement>(null), composing = useRef(false)
546
+ const [width, setWidth] = useState(0), [tickWidth, setTickWidth] = useState(1), [localZoom, setLocalZoom] = useState(1), [raw, setRaw] = useState<string>(), [previewTime, setPreviewTime] = useState<number>()
547
+ const span = Number.isFinite(durationMs) && durationMs! >= 0 ? durationMs : undefined
548
+ const timing = durationStatus ?? (span === undefined ? 'unknown' : 'confirmed')
549
+ const timed = span !== undefined && span > 0 && timing !== 'unknown'
550
+ const blocked = Boolean(disabled || loading), cannotSeek = Boolean(blocked || seeking || seekDisabled || !timed || timing !== 'confirmed')
551
+ const scale = Math.max(1, Math.min(16, Number.isFinite(zoom ?? localZoom) ? zoom ?? localZoom : 1))
552
+ const position = Number.isFinite(currentTimeMs) && currentTimeMs! >= 0 ? currentTimeMs : undefined
553
+ const shownPosition = previewTime ?? position
554
+ const zoomTo = (value: number) => { const next = Math.max(1, Math.min(16, value)); if (zoom === undefined) setLocalZoom(next); onZoomChange?.(next) }
555
+ useEffect(() => {
556
+ const element = viewport.current
557
+ if (!element) return
558
+ const update = () => {
559
+ const style = getComputedStyle(element)
560
+ setWidth(Math.max(0, element.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)))
561
+ if (tick.current) setTickWidth(tick.current.getBoundingClientRect().width)
562
+ }
563
+ const observer = new ResizeObserver(update); observer.observe(element); update()
564
+ return () => observer.disconnect()
565
+ }, [timed])
566
+ useEffect(() => { if (cannotSeek) { setPreviewTime(undefined); setRaw(undefined) } }, [cannotSeek, span])
567
+ const requestSeek = (next: number) => { if (!cannotSeek && span !== undefined && Number.isFinite(next)) onSeek(Math.max(0, Math.min(span, Math.round(next)))) }
568
+ const number = raw?.normalize('NFKC').trim(), milliseconds = number !== undefined && /^\d+(?:\.\d+)?$/.test(number) ? Number(number) * 1000 : NaN
569
+ const invalid = raw !== undefined && (!Number.isFinite(milliseconds) || milliseconds < 0 || span === undefined || milliseconds > span)
570
+ const commitPosition = () => { if (composing.current || raw === undefined || invalid || cannotSeek) return; setRaw(undefined); requestSeek(milliseconds) }
571
+ const rows = useMemo(() => tracks.map(track => ({ ...track, segments: track.segments.map(segment => ({ ...segment, wave: waveformPath(segment.waveform), positioned: timed && segment.timing !== 'unknown' && Number.isFinite(segment.startMs) && Number.isFinite(segment.endMs) && segment.startMs! >= 0 && segment.endMs! >= segment.startMs! && segment.endMs! <= span! })) })), [tracks, timed, span])
572
+ const pickerSegments = useMemo(() => {
573
+ const ids = new Set(segmentPicker?.ids)
574
+ return rows.find(track => track.id === segmentPicker?.track)?.segments.filter(segment => ids.has(segment.id) && segment.positioned && segment.timing === 'confirmed') ?? []
575
+ }, [rows, segmentPicker])
576
+ const steps = useMemo(() => {
577
+ const choices = new Map<string, TimelineSegment>()
578
+ for (const track of tracks) for (const segment of track.segments) if (segment.stepId && !choices.has(segment.stepId)) choices.set(segment.stepId, segment)
579
+ return [...choices.values()]
580
+ }, [tracks])
581
+ const ticks = useMemo(() => {
582
+ if (!timed || span === undefined) return []
583
+ const interval = span / Math.max(1, width * scale / tickWidth), power = 10 ** Math.floor(Math.log10(Math.max(1, interval)))
584
+ const unit = [1, 2, 5, 10].find(value => value * power >= interval)! * power
585
+ const marks = Array.from({ length: Math.min(200, Math.floor(span / unit) + 1) }, (_, index) => index * unit)
586
+ return [0, ...marks.filter(time => time > 0 && time < span && Math.min(time, span - time) / span * width * scale >= tickWidth), span]
587
+ }, [span, timed, width, scale, tickWidth])
588
+ const status = <div className="pm-ui-timeline__status" role="status">{loading ? labels.loading : timing === 'unknown' || span === undefined ? labels.unknown : `${timing === 'estimated' ? `${labels.estimated} · ` : ''}${timelineTime(span)}`}</div>
589
+ return <section className="pm-ui-timeline" data-variant={variant} data-density={density} data-disabled={blocked || undefined} data-segment-picker={segmentPicker?.track} aria-label={label} aria-busy={loading || seeking || undefined}>
590
+ <div className="pm-ui-timeline__tools">
591
+ <PmInput label={`${labels.position} (${labels.seconds})`} labelHidden={variant === 'editor'} width="time" density={density} inputMode="decimal" maxLength={32} value={raw ?? (position === undefined ? '' : (position / 1000).toFixed(3))} placeholder={labels.unknown} error={invalid ? labels.invalidPosition : undefined} disabled={cannotSeek}
592
+ onChange={event => setRaw(event.target.value)} onBlur={commitPosition}
593
+ onCompositionStart={() => { composing.current = true }} onCompositionEnd={() => { composing.current = false }}
594
+ onKeyDown={event => { if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; if (event.key === 'Enter') { event.preventDefault(); commitPosition() } else if (event.key === 'Escape') setRaw(undefined) }} />
595
+ {variant === 'editor' && status}
596
+ <div className="pm-ui-timeline__zoom">
597
+ <PmIconButton icon="minus" label={labels.zoomOut} density={density} disabled={blocked || scale <= 1} onClick={() => zoomTo(scale / 2)} />
598
+ <output className="pm-ui-timeline__scale">{Math.round(scale * 100)}%</output>
599
+ <PmIconButton icon="plus" label={labels.zoomIn} density={density} disabled={blocked || scale >= 16} onClick={() => zoomTo(scale * 2)} />
600
+ <PmIconButton icon="fit" label={labels.fit} density={density} disabled={blocked} onClick={() => { zoomTo(1); viewport.current?.scrollTo({ left: 0 }) }} />
601
+ </div>
602
+ </div>
603
+ {variant !== 'editor' && status}
604
+ <div className="pm-ui-timeline__tracks">
605
+ <div className="pm-ui-timeline__labels" aria-hidden="true">{timed && <><span /><span /></>}{tracks.map(track => <span key={track.id} data-track-label="">{track.label}</span>)}</div>
606
+ <div ref={viewport} className="pm-ui-timeline__viewport" tabIndex={0} aria-label={label} aria-describedby={hintId}>
607
+ <div className="pm-ui-timeline__canvas" style={{ width: timed ? `${width * scale}px` : '100%' }}>
608
+ {timed && <><div className="pm-ui-timeline__ruler" aria-hidden="true" onClick={event => { const rect = event.currentTarget.getBoundingClientRect(); requestSeek((event.clientX - rect.left) / rect.width * span!) }}>
609
+ {ticks.map((time, index) => <span key={time} ref={index === 0 ? tick : undefined} data-end={time === span || undefined} style={{ left: `${time / span! * 100}%` }}>{timelineTime(time).replace(/\.000$/, '')}</span>)}
610
+ </div><PmRange label={labels.seek} value={position ?? 0} max={span!} disabled={cannotSeek} density={density} valueText={timelineTime} onPreviewChange={setPreviewTime} onValueCommit={requestSeek} /></>}
611
+ {rows.map(track => <div key={track.id} className="pm-ui-timeline__track" data-track={track.id} role="group" aria-label={track.label}>
612
+ {track.segments.map(segment => {
613
+ const description = `${segment.label}${segment.statusLabel ? ` · ${segment.statusLabel}` : ''} · ${segment.positioned ? `${segment.timing === 'estimated' ? `${labels.estimated} · ` : ''}${timelineTime(segment.startMs!)}–${timelineTime(segment.endMs!)}` : labels.unknown}`
614
+ const content = <>{segment.wave && segment.positioned && <svg className="pm-ui-timeline__wave" viewBox="0 0 160 20" preserveAspectRatio="none" aria-hidden="true"><path d={segment.wave} /></svg>}<span>{segment.label}</span></>
615
+ return segment.positioned ? <button key={segment.id} type="button" className="pm-ui-timeline__segment" data-timing={segment.timing} aria-label={description} title={description} aria-pressed={segment.stepId ? segment.stepId === selectedStepId : undefined} disabled={blocked || segment.disabled || !segment.stepId} onClick={() => { if (onSelectSegment) onSelectSegment(track.id, segment); else if (segment.stepId) onSelectStep(segment.stepId) }} style={{ left: `${segment.startMs! / span! * 100}%`, width: `${(segment.endMs! - segment.startMs!) / span! * 100}%` }}>{content}</button>
616
+ : <span className="pm-ui-timeline__unknown" key={segment.id} title={description}>{segment.label} · {labels.unknown}</span>
617
+ })}
618
+ </div>)}
619
+ {timed && shownPosition !== undefined && <span className="pm-ui-timeline__cursor" data-preview={previewTime !== undefined || undefined} style={{ left: `${Math.max(0, Math.min(1, shownPosition / span!)) * 100}%` }} aria-hidden="true" />}
620
+ </div>
621
+ </div>
622
+ </div>
623
+ {segmentPicker && pickerSegments.length > 0 && <div className="pm-ui-timeline__segment-picker"><PmSelect density="touch" label={segmentPicker.label} value="" disabled={cannotSeek || !onSelectSegment}
624
+ onChange={event => { const segment = pickerSegments.find(segment => segment.id === event.target.value); if (segment && !cannotSeek) onSelectSegment?.(segmentPicker.track, segment) }}>
625
+ <option value="">{segmentPicker.placeholder}</option>{pickerSegments.map(segment => <option key={segment.id} value={segment.id} disabled={segment.disabled}>{timelineTime(segment.startMs!)} · {segment.label}</option>)}
626
+ </PmSelect></div>}
627
+ {showSteps && <div className="pm-ui-timeline__steps">{steps.map(segment => <PmButton key={segment.stepId} density={density} disabled={blocked || segment.disabled} aria-pressed={segment.stepId === selectedStepId} variant={segment.stepId === selectedStepId ? 'accent' : 'default'} onClick={() => onSelectStep(segment.stepId!)}>{segment.label}</PmButton>)}</div>}
628
+ {!tracks.some(track => track.segments.length) && <PmNotice>{labels.empty}</PmNotice>}
629
+ <p id={hintId} className="pm-ui-sr-only">{labels.seekHint}</p>
630
+ </section>
631
+ }