@elabs-ai/components-ai 4.0.0 → 4.1.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 (128) hide show
  1. package/README.md +61 -11
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1109 -202
  9. package/dist/index.js +4867 -2878
  10. package/dist/index.js.map +1 -1
  11. package/package.json +31 -16
  12. package/src/_audio-player-media-chrome.tsx +102 -15
  13. package/src/_flow-boundary.tsx +68 -49
  14. package/src/_lazy-boundary-conformance.ts +38 -0
  15. package/src/_lazy-engine-boundary.tsx +61 -0
  16. package/src/_lazy-mermaid-absent.test.ts +53 -0
  17. package/src/_lazy-mermaid.test.ts +15 -0
  18. package/src/_lazy-mermaid.ts +24 -1
  19. package/src/_mermaid-error-panel.test.tsx +50 -0
  20. package/src/_mermaid-error-panel.tsx +66 -0
  21. package/src/_persona-rive.tsx +62 -10
  22. package/src/_streamdown-i18n.ts +21 -1
  23. package/src/_streamdown-safety.ts +170 -0
  24. package/src/agent-event.stories.tsx +97 -0
  25. package/src/agent-event.test.tsx +145 -0
  26. package/src/agent-event.tsx +187 -0
  27. package/src/agent-timeline.stories.tsx +9 -1
  28. package/src/agent.test.tsx +47 -0
  29. package/src/agent.tsx +28 -14
  30. package/src/agentic-workspace.stories.tsx +27 -20
  31. package/src/artifact.tsx +15 -12
  32. package/src/audio-player.test.tsx +78 -0
  33. package/src/audio-player.tsx +243 -56
  34. package/src/audio-visualizer.stories.tsx +126 -0
  35. package/src/audio-visualizer.test.tsx +438 -0
  36. package/src/audio-visualizer.tsx +367 -0
  37. package/src/canvas.stories.tsx +150 -1
  38. package/src/chat-shell.stories.tsx +18 -3
  39. package/src/chat.stories.tsx +16 -2
  40. package/src/code-block.stories.tsx +9 -1
  41. package/src/composer.stories.tsx +372 -9
  42. package/src/composer.test.tsx +357 -6
  43. package/src/composer.tsx +217 -35
  44. package/src/confirmation.stories.tsx +72 -1
  45. package/src/confirmation.test.tsx +216 -2
  46. package/src/confirmation.tsx +263 -3
  47. package/src/context-panel.stories.tsx +9 -1
  48. package/src/context-panel.tsx +2 -1
  49. package/src/conversation.stories.tsx +63 -2
  50. package/src/conversation.test.tsx +13 -0
  51. package/src/conversation.tsx +20 -2
  52. package/src/diff-view.stories.tsx +196 -0
  53. package/src/diff-view.test.tsx +188 -0
  54. package/src/diff-view.tsx +642 -0
  55. package/src/gallery.tsx +4 -2
  56. package/src/index.ts +14 -4
  57. package/src/jsx-preview.stories.tsx +2 -2
  58. package/src/markdown-view.stories.tsx +92 -1
  59. package/src/markdown-view.test.tsx +232 -1
  60. package/src/markdown-view.tsx +150 -6
  61. package/src/message-compare.stories.tsx +175 -0
  62. package/src/message-compare.test.tsx +207 -0
  63. package/src/message-compare.tsx +453 -0
  64. package/src/message-form.stories.tsx +29 -1
  65. package/src/message.stories.tsx +9 -1
  66. package/src/message.test.tsx +176 -0
  67. package/src/message.tsx +90 -4
  68. package/src/microcopy.test.tsx +40 -0
  69. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  70. package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
  71. package/src/model-provider-logo.tsx +149 -0
  72. package/src/permission-mode-select.stories.tsx +82 -0
  73. package/src/permission-mode-select.test.tsx +100 -0
  74. package/src/permission-mode-select.tsx +137 -0
  75. package/src/persona-missing-peer.test.tsx +54 -0
  76. package/src/persona.tsx +68 -22
  77. package/src/plan.stories.tsx +166 -0
  78. package/src/plan.test.tsx +267 -0
  79. package/src/plan.tsx +182 -20
  80. package/src/prompt-input-effort.stories.tsx +123 -0
  81. package/src/prompt-input-effort.test.tsx +83 -0
  82. package/src/prompt-input-effort.tsx +136 -0
  83. package/src/prompt-input-mode.stories.tsx +108 -0
  84. package/src/prompt-input-mode.test.tsx +99 -0
  85. package/src/prompt-input-mode.tsx +169 -0
  86. package/src/prompt-input-slash.stories.tsx +211 -0
  87. package/src/prompt-input-slash.test.tsx +262 -0
  88. package/src/prompt-input-slash.tsx +541 -0
  89. package/src/prompt-input.stories.tsx +2 -2
  90. package/src/reasoning.tsx +27 -13
  91. package/src/sandbox.stories.tsx +9 -1
  92. package/src/schema-display.tsx +5 -2
  93. package/src/selection-toolbar.stories.tsx +9 -1
  94. package/src/session-header.stories.tsx +128 -0
  95. package/src/session-header.test.tsx +138 -0
  96. package/src/session-header.tsx +243 -0
  97. package/src/session-status-bar.stories.tsx +73 -0
  98. package/src/session-status-bar.test.tsx +94 -0
  99. package/src/session-status-bar.tsx +165 -0
  100. package/src/snippet.stories.tsx +9 -1
  101. package/src/stack-trace.tsx +6 -2
  102. package/src/streamdown-i18n.test.tsx +1 -1
  103. package/src/task.stories.tsx +13 -3
  104. package/src/templates-ai-assistant.stories.tsx +21 -1
  105. package/src/token-usage.stories.tsx +24 -0
  106. package/src/token-usage.test.tsx +92 -0
  107. package/src/{context.tsx → token-usage.tsx} +67 -53
  108. package/src/tool-result-card.stories.tsx +9 -1
  109. package/src/tool.stories.tsx +13 -3
  110. package/src/tool.tsx +15 -8
  111. package/src/turn-status.stories.tsx +124 -0
  112. package/src/turn-status.test.tsx +74 -0
  113. package/src/turn-status.tsx +174 -0
  114. package/src/use-audio-level.ts +104 -0
  115. package/src/web-preview.tsx +8 -3
  116. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  117. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  118. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  119. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  120. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  121. package/src/_interactive-terminal-xterm.ts +0 -32
  122. package/src/blocks-ai-composer.stories.tsx +0 -83
  123. package/src/context.stories.tsx +0 -16
  124. package/src/interactive-terminal.stories.tsx +0 -165
  125. package/src/interactive-terminal.test.tsx +0 -448
  126. package/src/interactive-terminal.tsx +0 -444
  127. package/src/model-selector.tsx +0 -225
  128. package/src/terminal.tsx +0 -244
@@ -1,46 +1,191 @@
1
1
  "use client";
2
2
 
3
- import { Skeleton } from "@elabs-ai/components-ui";
3
+ import { Skeleton, isOptionalPeerMissing, useLocale } from "@elabs-ai/components-ui";
4
4
  import { cn } from "@elabs-ai/components-ui/lib/cn";
5
5
  import type { Experimental_SpeechResult as SpeechResult } from "ai";
6
- import type {
7
- MediaControlBar,
8
- MediaController,
9
- MediaDurationDisplay,
10
- MediaMuteButton,
11
- MediaPlayButton,
12
- MediaSeekBackwardButton,
13
- MediaSeekForwardButton,
14
- MediaTimeDisplay,
15
- MediaTimeRange,
16
- MediaVolumeRange,
17
- } from "media-chrome/react";
18
- import type { ComponentProps, ComponentType } from "react";
6
+ import { VolumeOffIcon } from "lucide-react";
7
+ import type { ComponentProps, ComponentType, HTMLAttributes } from "react";
19
8
  import { lazy, Suspense } from "react";
20
9
 
10
+ import { LazyEngineBoundary } from "./_lazy-engine-boundary";
11
+
21
12
  /**
22
13
  * media-chrome lives behind a dynamic import — it declares no `sideEffects`, so
23
14
  * a static import would put the whole custom-element library in every consumer's
24
15
  * entry chunk, `AudioPlayer` rendered or not. Every value import lives in
25
- * `_audio-player-media-chrome.tsx`; the element types above are TYPE imports and
26
- * erase. See ADR 0019 and `pnpm heavy-deps:check`.
16
+ * `_audio-player-media-chrome.tsx`. See ADR 0019 and `pnpm heavy-deps:check`.
27
17
  *
28
18
  * All ten parts resolve from the SAME module specifier, so they share one chunk:
29
19
  * once `AudioPlayer` has loaded it, the controls inside it resolve from cache.
20
+ *
21
+ * Issue #101: `media-chrome` is an OPTIONAL peer, so no PUBLIC export's type
22
+ * may structurally reference `media-chrome/react`'s own types (previously
23
+ * `ComponentProps<typeof MediaController>` and nine siblings, imported as
24
+ * TYPE-only above) — doing so names `media-chrome/react`'s module specifier in
25
+ * this package's generated root `.d.ts` and hands a `skipLibCheck: false`
26
+ * consumer who has correctly omitted the peer a `TS2307` just for importing
27
+ * the barrel — not just for using `AudioPlayer`. `AudioPlayerPartProps` below
28
+ * is an OWNED base type: `media-chrome/react`'s wrapper
29
+ * (`media-chrome/react`'s `createComponent`, from the `ce-la-react` helper it
30
+ * is built on) types every part as `Omit<HTMLAttributes<I>, …> &
31
+ * Partial<the element's own instance members>` — i.e. ordinary HTML attributes
32
+ * plus a handful of optional custom attributes. `AudioPlayerPartProps` itself
33
+ * stays ordinary HTML attributes only; the PRIMITIVE-typed (`string` /
34
+ * `boolean` / `number`) extra instance members of `<media-controller>` and
35
+ * `<media-seek-forward-button>` — the two elements measured by the round-2
36
+ * validator — are mirrored one-by-one on `AudioPlayerProps` and
37
+ * `AudioPlayerSeekForwardButtonProps` below (each a single declaration line
38
+ * referencing nothing peer-owned, the same technique `seekOffset` already
39
+ * used). What stays narrowed to `AudioPlayerPartProps` alone are the
40
+ * PEER/COMPLEX-typed members (`MediaStore`, `AttributeTokenList`,
41
+ * `MediaTooltip`, `TooltipPlacement`, DOM-element and `HTMLMediaElement`
42
+ * callbacks, methods) and every member of the other eight parts — restoring
43
+ * those would re-import the peer type graph this fix exists to avoid. See the
44
+ * CHANGELOG's "Breaking (types)" entry for the exact narrowed set.
45
+ * `AudioPlayerPartProps` is therefore NOT a full replica of media-chrome's
46
+ * surface, only the base every part actually uses and re-exports (the same
47
+ * scoping `TerminalColorTheme` uses for xterm's `ITheme` in
48
+ * `packages/terminal/src/interactive-terminal.tsx`, also issue #101). The ten
49
+ * conformance assertions at the bottom of `_audio-player-media-chrome.tsx`
50
+ * (which still imports the real peer types — that module is reached only
51
+ * through `lazy()` and never sits in the barrel's declaration graph) prove
52
+ * every owned type here stays assignable to its real media-chrome counterpart;
53
+ * a peer version bump that narrows a prop incompatibly fails
54
+ * `pnpm --filter @elabs-ai/components-ai typecheck` locally instead of
55
+ * reaching a consumer as silent drift.
56
+ *
57
+ * Deliberately no `ref` field: `ComponentProps<typeof MediaController>` (and
58
+ * its nine siblings) included one via `RefAttributes<I>`, but `RefObject<T>`'s
59
+ * mutable `current` makes ref types INVARIANT in `T` — a `Ref<HTMLElement>`
60
+ * can never be assignable to a real element's own `Ref<MediaController>` (the
61
+ * custom element subclass carries private/extra members `HTMLElement` lacks),
62
+ * so keeping it would make every owned type fail its own conformance
63
+ * assertion below. This IS a real-world break for a React 19 consumer: none
64
+ * of the ten parts this package renders is wrapped in `forwardRef`, but
65
+ * `MediaController` (and its siblings) are genuine `forwardRef` components,
66
+ * and React 19's ref-as-prop carries a `ref` straight through an ordinary
67
+ * function component's `{...props}` spread to the real underlying element —
68
+ * see the CHANGELOG's "Breaking (types)" entry for the measured detail. It is
69
+ * inert only under React 18.
30
70
  */
71
+ export type AudioPlayerPartProps = HTMLAttributes<HTMLElement>;
72
+
31
73
  type MediaChromeModule = typeof import("./_audio-player-media-chrome");
32
74
 
33
75
  const lazyPart = <P,>(pick: (module: MediaChromeModule) => ComponentType<P>) =>
34
76
  lazy(() => import("./_audio-player-media-chrome").then((m) => ({ default: pick(m) })));
35
77
 
36
- export type AudioPlayerProps = Omit<ComponentProps<typeof MediaController>, "audio">;
78
+ /**
79
+ * The primitive-typed (`string`/`boolean`/`number`) extra instance members of
80
+ * `media-chrome/react`'s `MediaController` — restored per issue #101's round-2
81
+ * validation (R2): each is a single declaration line mirroring the real peer
82
+ * type's name and shape, referencing nothing peer-owned, so it costs nothing
83
+ * against the `.d.ts` leak this fix exists to prevent. Deliberately EXCLUDED:
84
+ * `audio` (already narrowed out — `_audio-player-media-chrome.tsx`'s
85
+ * conformance assertion Omits it from the real `ComponentProps`, since this
86
+ * package renders its own `<AudioPlayerElement>` instead of passing one
87
+ * through), and every PEER/COMPLEX-typed member (`mediaStore`, `hotkeys`
88
+ * (`AttributeTokenList`), `media`/`fullscreenElement` (DOM elements),
89
+ * `mediaStateReceivers`, `associatedElementSubscriptions`, every
90
+ * `HTMLMediaElement` callback and every method) — those stay narrowed to
91
+ * `AudioPlayerPartProps` alone; restoring them would re-import the peer type
92
+ * graph. See `_audio-player-media-chrome.tsx`'s
93
+ * `_AudioPlayerPropsConformance` assertion, which fails typecheck if this
94
+ * interface ever drifts wider than the real element's own props.
95
+ *
96
+ * Doc comments below are inferred from each member's name and media-chrome's
97
+ * public attribute-naming convention (`no*` = boolean opt-out of the
98
+ * corresponding default-on behavior) — media-chrome ships no per-property doc
99
+ * comments in its own `.d.ts` or README to copy from. Treat them as a reading
100
+ * aid, not an authoritative spec; verify against media-chrome's source before
101
+ * relying on exact semantics.
102
+ */
103
+ export interface AudioPlayerProps extends AudioPlayerPartProps {
104
+ /** Seconds of inactivity before controls hide; a string because it mirrors the HTML attribute value. */
105
+ autohide?: string;
106
+ /** Also autohide while the pointer is over the control elements themselves, not just the media. */
107
+ autohideOverControls?: boolean;
108
+ /** Breakpoint definitions (space-separated `name:width` pairs) driving `breakpointsComputed`. */
109
+ breakpoints?: string;
110
+ /** Whether the controller has computed its current breakpoint(s) at least once. */
111
+ breakpointsComputed?: boolean;
112
+ /** Fallback duration (seconds) to display before the real media duration is known. */
113
+ defaultDuration?: number;
114
+ /** Default stream type ("on-demand" or "live") before the media has resolved its own. */
115
+ defaultStreamType?: string;
116
+ /** Whether subtitles/captions are enabled by default. */
117
+ defaultSubtitles?: boolean;
118
+ /** Disables the controller's built-in tap/gesture handling (e.g. tap-to-play, double-tap-to-seek). */
119
+ gesturesDisabled?: boolean;
120
+ /** Whether hotkey keyboard control is currently active on the controller. */
121
+ keyboardControl?: boolean;
122
+ /** Space-separated list of hotkey names currently in use, for conflict detection with other listeners. */
123
+ keysUsed?: string;
124
+ /** Seconds behind the live edge still considered "at" live, for a live stream's seek range. */
125
+ liveEdgeOffset?: number;
126
+ /** Disables the controller's autohide-controls-on-inactivity behavior entirely. */
127
+ noAutohide?: boolean;
128
+ /** Disables automatically seeking back to the live edge after a manual seek on a live stream. */
129
+ noAutoSeekToLive?: boolean;
130
+ /** Opts out of the controller creating its own default internal media store. */
131
+ noDefaultStore?: boolean;
132
+ /** Disables the controller's built-in keyboard hotkeys entirely. */
133
+ noHotkeys?: boolean;
134
+ /** Opts out of remembering the user's mute preference across sessions. */
135
+ noMutedPref?: boolean;
136
+ /** Opts out of remembering the user's subtitles-language preference across sessions. */
137
+ noSubtitlesLangPref?: boolean;
138
+ /** Opts out of remembering the user's volume preference across sessions. */
139
+ noVolumePref?: boolean;
140
+ /** The language the controller resolved for its UI text, after applying any language preference. */
141
+ resolvedLang?: string;
142
+ /** Whether the user has interacted with the controller yet (affects autoplay-adjacent UI). */
143
+ userInteractive?: boolean;
144
+ }
37
145
 
38
146
  const AudioPlayerImpl = lazyPart<AudioPlayerProps>((m) => m.AudioPlayer);
39
147
 
148
+ /**
149
+ * The settled-failure stand-in for the `LazyEngineBoundary` above — rendered
150
+ * once the `media-chrome` load has genuinely failed, never while it is still
151
+ * pending (that stays `Skeleton`, via the `Suspense fallback` below). A
152
+ * loading skeleton left in place after a settled failure reads as "still
153
+ * loading, forever" (loading-states.md) — this is a terminal state, so it
154
+ * gets a real (if compact) notice instead, sized to the same `h-10` slot so
155
+ * nothing shifts when the boundary trips.
156
+ */
157
+ const AudioPlayerMissing = ({ className, error }: { className?: string; error: unknown }) => {
158
+ const { t } = useLocale();
159
+ const isPeerMissing = isOptionalPeerMissing(error);
160
+ return (
161
+ <div
162
+ className={cn(
163
+ "flex h-10 w-full items-center gap-2 rounded-md border border-dashed border-border bg-muted/30 px-3 text-caption text-muted-foreground",
164
+ className,
165
+ )}
166
+ role={isPeerMissing ? "status" : "alert"}
167
+ >
168
+ <VolumeOffIcon aria-hidden="true" className="size-4 shrink-0" />
169
+ <span className="truncate">
170
+ {isPeerMissing
171
+ ? t("ai.error.engineMissingBody", {
172
+ feature: t("ai.audioPlayer.feature"),
173
+ packages: "media-chrome",
174
+ })
175
+ : t("ai.audioPlayer.renderError")}
176
+ </span>
177
+ </div>
178
+ );
179
+ };
180
+
40
181
  export const AudioPlayer = ({ className, ...props }: AudioPlayerProps) => (
41
- <Suspense fallback={<Skeleton className={cn("h-10 w-full rounded-md", className)} />}>
42
- <AudioPlayerImpl className={className} data-slot="audio-player" {...props} />
43
- </Suspense>
182
+ <LazyEngineBoundary
183
+ renderMissing={(error) => <AudioPlayerMissing className={className} error={error} />}
184
+ >
185
+ <Suspense fallback={<Skeleton className={cn("h-10 w-full rounded-md", className)} />}>
186
+ <AudioPlayerImpl className={className} data-slot="audio-player" {...props} />
187
+ </Suspense>
188
+ </LazyEngineBoundary>
44
189
  );
45
190
 
46
191
  export type AudioPlayerElementProps = Omit<ComponentProps<"audio">, "src"> &
@@ -63,108 +208,150 @@ export const AudioPlayerElement = ({ ...props }: AudioPlayerElementProps) => (
63
208
  />
64
209
  );
65
210
 
66
- export type AudioPlayerControlBarProps = ComponentProps<typeof MediaControlBar>;
211
+ export type AudioPlayerControlBarProps = AudioPlayerPartProps;
67
212
 
68
213
  const AudioPlayerControlBarImpl = lazyPart<AudioPlayerControlBarProps>(
69
214
  (m) => m.AudioPlayerControlBar,
70
215
  );
71
216
 
72
217
  export const AudioPlayerControlBar = (props: AudioPlayerControlBarProps) => (
73
- <Suspense fallback={null}>
74
- <AudioPlayerControlBarImpl data-slot="audio-player-control-bar" {...props} />
75
- </Suspense>
218
+ <LazyEngineBoundary renderMissing={() => null}>
219
+ <Suspense fallback={null}>
220
+ <AudioPlayerControlBarImpl data-slot="audio-player-control-bar" {...props} />
221
+ </Suspense>
222
+ </LazyEngineBoundary>
76
223
  );
77
224
 
78
- export type AudioPlayerPlayButtonProps = ComponentProps<typeof MediaPlayButton>;
225
+ export type AudioPlayerPlayButtonProps = AudioPlayerPartProps;
79
226
 
80
227
  const AudioPlayerPlayButtonImpl = lazyPart<AudioPlayerPlayButtonProps>(
81
228
  (m) => m.AudioPlayerPlayButton,
82
229
  );
83
230
 
84
231
  export const AudioPlayerPlayButton = (props: AudioPlayerPlayButtonProps) => (
85
- <Suspense fallback={null}>
86
- <AudioPlayerPlayButtonImpl data-slot="audio-player-play-button" {...props} />
87
- </Suspense>
232
+ <LazyEngineBoundary renderMissing={() => null}>
233
+ <Suspense fallback={null}>
234
+ <AudioPlayerPlayButtonImpl data-slot="audio-player-play-button" {...props} />
235
+ </Suspense>
236
+ </LazyEngineBoundary>
88
237
  );
89
238
 
90
- export type AudioPlayerSeekBackwardButtonProps = ComponentProps<typeof MediaSeekBackwardButton>;
239
+ export interface AudioPlayerSeekBackwardButtonProps extends AudioPlayerPartProps {
240
+ /** Skip distance in seconds. Mirrors the element's own default (10). */
241
+ seekOffset?: number;
242
+ }
91
243
 
92
244
  const AudioPlayerSeekBackwardButtonImpl = lazyPart<AudioPlayerSeekBackwardButtonProps>(
93
245
  (m) => m.AudioPlayerSeekBackwardButton,
94
246
  );
95
247
 
96
248
  export const AudioPlayerSeekBackwardButton = (props: AudioPlayerSeekBackwardButtonProps) => (
97
- <Suspense fallback={null}>
98
- <AudioPlayerSeekBackwardButtonImpl data-slot="audio-player-seek-backward-button" {...props} />
99
- </Suspense>
249
+ <LazyEngineBoundary renderMissing={() => null}>
250
+ <Suspense fallback={null}>
251
+ <AudioPlayerSeekBackwardButtonImpl data-slot="audio-player-seek-backward-button" {...props} />
252
+ </Suspense>
253
+ </LazyEngineBoundary>
100
254
  );
101
255
 
102
- export type AudioPlayerSeekForwardButtonProps = ComponentProps<typeof MediaSeekForwardButton>;
256
+ /**
257
+ * Primitive-typed extra members restored per issue #101's round-2 validation
258
+ * (R2) — same technique and same inferred-from-naming caveat as
259
+ * `AudioPlayerProps` above. Deliberately still narrowed out:
260
+ * `keysUsed` (here typed `string[]`, unlike the controller's own `string`
261
+ * member of the same name — an array is not primitive) and the DOM/method
262
+ * members `disable`/`enable`/`handleClick`/`tooltipEl`/`tooltipPlacement`.
263
+ */
264
+ export interface AudioPlayerSeekForwardButtonProps extends AudioPlayerPartProps {
265
+ /** Whether the button is disabled. */
266
+ disabled?: boolean;
267
+ /** ID of the `<media-controller>` element this button controls, when it is not an ancestor. */
268
+ mediaController?: string;
269
+ /** The media's current playback time (seconds), as reflected by the controller's media store. */
270
+ mediaCurrentTime?: number;
271
+ /** Skip distance in seconds. Mirrors the element's own default (10). */
272
+ seekOffset?: number;
273
+ /** Suppresses the built-in hover tooltip on this button. */
274
+ noTooltip?: boolean;
275
+ /** Prevents the button's default click handling (for a consumer that wants to fully own the behavior). */
276
+ preventClick?: boolean;
277
+ }
103
278
 
104
279
  const AudioPlayerSeekForwardButtonImpl = lazyPart<AudioPlayerSeekForwardButtonProps>(
105
280
  (m) => m.AudioPlayerSeekForwardButton,
106
281
  );
107
282
 
108
283
  export const AudioPlayerSeekForwardButton = (props: AudioPlayerSeekForwardButtonProps) => (
109
- <Suspense fallback={null}>
110
- <AudioPlayerSeekForwardButtonImpl data-slot="audio-player-seek-forward-button" {...props} />
111
- </Suspense>
284
+ <LazyEngineBoundary renderMissing={() => null}>
285
+ <Suspense fallback={null}>
286
+ <AudioPlayerSeekForwardButtonImpl data-slot="audio-player-seek-forward-button" {...props} />
287
+ </Suspense>
288
+ </LazyEngineBoundary>
112
289
  );
113
290
 
114
- export type AudioPlayerTimeDisplayProps = ComponentProps<typeof MediaTimeDisplay>;
291
+ export type AudioPlayerTimeDisplayProps = AudioPlayerPartProps;
115
292
 
116
293
  const AudioPlayerTimeDisplayImpl = lazyPart<AudioPlayerTimeDisplayProps>(
117
294
  (m) => m.AudioPlayerTimeDisplay,
118
295
  );
119
296
 
120
297
  export const AudioPlayerTimeDisplay = (props: AudioPlayerTimeDisplayProps) => (
121
- <Suspense fallback={null}>
122
- <AudioPlayerTimeDisplayImpl data-slot="audio-player-time-display" {...props} />
123
- </Suspense>
298
+ <LazyEngineBoundary renderMissing={() => null}>
299
+ <Suspense fallback={null}>
300
+ <AudioPlayerTimeDisplayImpl data-slot="audio-player-time-display" {...props} />
301
+ </Suspense>
302
+ </LazyEngineBoundary>
124
303
  );
125
304
 
126
- export type AudioPlayerTimeRangeProps = ComponentProps<typeof MediaTimeRange>;
305
+ export type AudioPlayerTimeRangeProps = AudioPlayerPartProps;
127
306
 
128
307
  const AudioPlayerTimeRangeImpl = lazyPart<AudioPlayerTimeRangeProps>((m) => m.AudioPlayerTimeRange);
129
308
 
130
309
  export const AudioPlayerTimeRange = (props: AudioPlayerTimeRangeProps) => (
131
- <Suspense fallback={null}>
132
- <AudioPlayerTimeRangeImpl data-slot="audio-player-time-range" {...props} />
133
- </Suspense>
310
+ <LazyEngineBoundary renderMissing={() => null}>
311
+ <Suspense fallback={null}>
312
+ <AudioPlayerTimeRangeImpl data-slot="audio-player-time-range" {...props} />
313
+ </Suspense>
314
+ </LazyEngineBoundary>
134
315
  );
135
316
 
136
- export type AudioPlayerDurationDisplayProps = ComponentProps<typeof MediaDurationDisplay>;
317
+ export type AudioPlayerDurationDisplayProps = AudioPlayerPartProps;
137
318
 
138
319
  const AudioPlayerDurationDisplayImpl = lazyPart<AudioPlayerDurationDisplayProps>(
139
320
  (m) => m.AudioPlayerDurationDisplay,
140
321
  );
141
322
 
142
323
  export const AudioPlayerDurationDisplay = (props: AudioPlayerDurationDisplayProps) => (
143
- <Suspense fallback={null}>
144
- <AudioPlayerDurationDisplayImpl data-slot="audio-player-duration-display" {...props} />
145
- </Suspense>
324
+ <LazyEngineBoundary renderMissing={() => null}>
325
+ <Suspense fallback={null}>
326
+ <AudioPlayerDurationDisplayImpl data-slot="audio-player-duration-display" {...props} />
327
+ </Suspense>
328
+ </LazyEngineBoundary>
146
329
  );
147
330
 
148
- export type AudioPlayerMuteButtonProps = ComponentProps<typeof MediaMuteButton>;
331
+ export type AudioPlayerMuteButtonProps = AudioPlayerPartProps;
149
332
 
150
333
  const AudioPlayerMuteButtonImpl = lazyPart<AudioPlayerMuteButtonProps>(
151
334
  (m) => m.AudioPlayerMuteButton,
152
335
  );
153
336
 
154
337
  export const AudioPlayerMuteButton = (props: AudioPlayerMuteButtonProps) => (
155
- <Suspense fallback={null}>
156
- <AudioPlayerMuteButtonImpl data-slot="audio-player-mute-button" {...props} />
157
- </Suspense>
338
+ <LazyEngineBoundary renderMissing={() => null}>
339
+ <Suspense fallback={null}>
340
+ <AudioPlayerMuteButtonImpl data-slot="audio-player-mute-button" {...props} />
341
+ </Suspense>
342
+ </LazyEngineBoundary>
158
343
  );
159
344
 
160
- export type AudioPlayerVolumeRangeProps = ComponentProps<typeof MediaVolumeRange>;
345
+ export type AudioPlayerVolumeRangeProps = AudioPlayerPartProps;
161
346
 
162
347
  const AudioPlayerVolumeRangeImpl = lazyPart<AudioPlayerVolumeRangeProps>(
163
348
  (m) => m.AudioPlayerVolumeRange,
164
349
  );
165
350
 
166
351
  export const AudioPlayerVolumeRange = (props: AudioPlayerVolumeRangeProps) => (
167
- <Suspense fallback={null}>
168
- <AudioPlayerVolumeRangeImpl data-slot="audio-player-volume-range" {...props} />
169
- </Suspense>
352
+ <LazyEngineBoundary renderMissing={() => null}>
353
+ <Suspense fallback={null}>
354
+ <AudioPlayerVolumeRangeImpl data-slot="audio-player-volume-range" {...props} />
355
+ </Suspense>
356
+ </LazyEngineBoundary>
170
357
  );
@@ -0,0 +1,126 @@
1
+ /**
2
+ * AudioVisualizer — a live mic-level / waveform meter (issue #21).
3
+ *
4
+ * Presentation-only (D5): it never calls `getUserMedia` or owns an
5
+ * `AudioContext` — every story below feeds it a plain `levels` array, exactly
6
+ * as a real consumer would from their own analyser loop (or the opt-in
7
+ * `useAudioLevel` hook, which these stories deliberately do NOT use, since it
8
+ * would require a live microphone).
9
+ */
10
+ import type { Meta, StoryObj } from "@storybook/react-vite";
11
+ import { expect, within } from "storybook/test";
12
+ import { useEffect, useState } from "react";
13
+
14
+ import { AudioVisualizer } from "./audio-visualizer";
15
+
16
+ const QUIET_LEVELS = Array.from({ length: 32 }, () => 0.02);
17
+ const SPEECH_LEVELS = Array.from({ length: 32 }, (_, i) =>
18
+ Math.max(0.05, Math.abs(Math.sin(i / 2.3)) * 0.9),
19
+ );
20
+
21
+ const meta = {
22
+ title: "AI/AudioVisualizer",
23
+ component: AudioVisualizer,
24
+ parameters: {
25
+ layout: "padded",
26
+ docs: {
27
+ description: {
28
+ component:
29
+ 'Canvas-drawn mic-level meter driven entirely by a `levels` prop — the component never touches the microphone itself. Level is readable from bar/wave HEIGHT, never colour alone; the canvas stays `aria-hidden` and a throttled `role="status"` text alternative reports the discretized state.',
30
+ },
31
+ },
32
+ },
33
+ args: { levels: SPEECH_LEVELS },
34
+ tags: ["autodocs"],
35
+ } satisfies Meta<typeof AudioVisualizer>;
36
+
37
+ export default meta;
38
+ type Story = StoryObj<typeof meta>;
39
+
40
+ /** Bars fed a moderate, varying speech-like signal. */
41
+ export const Default: Story = {
42
+ play: async ({ canvasElement }) => {
43
+ const canvas = within(canvasElement);
44
+ expect(canvas.getByRole("status")).toHaveTextContent("Microphone active");
45
+ },
46
+ };
47
+
48
+ /** The `"wave"` variant — a filled envelope through the current samples. */
49
+ export const Wave: Story = {
50
+ args: { variant: "wave" },
51
+ };
52
+
53
+ /**
54
+ * No stream connected yet — the not-ready state (`loading`). Renders the flat
55
+ * idle baseline instead of `levels` and announces "Microphone not connected".
56
+ */
57
+ export const Loading: Story = {
58
+ args: { loading: true },
59
+ play: async ({ canvasElement }) => {
60
+ const canvas = within(canvasElement);
61
+ expect(canvas.getByRole("status")).toHaveTextContent("Microphone not connected");
62
+ },
63
+ };
64
+
65
+ /** A connected mic picking up nothing above the noise floor. */
66
+ export const Silent: Story = {
67
+ args: { levels: QUIET_LEVELS },
68
+ play: async ({ canvasElement }) => {
69
+ const canvas = within(canvasElement);
70
+ expect(canvas.getByRole("status")).toHaveTextContent("No input detected");
71
+ },
72
+ };
73
+
74
+ /**
75
+ * A surface that already renders its own live region elsewhere opts out with
76
+ * `statusLabel={null}` so assistive tech is not told twice.
77
+ */
78
+ export const NoStatusAnnouncement: Story = {
79
+ args: { statusLabel: null },
80
+ play: async ({ canvasElement }) => {
81
+ const canvas = within(canvasElement);
82
+ expect(canvas.queryByRole("status")).not.toBeInTheDocument();
83
+ },
84
+ };
85
+
86
+ /**
87
+ * A single reduced-motion FRAME. Under `prefers-reduced-motion`,
88
+ * `AudioVisualizer` skips its internal smoothing animation and paints
89
+ * `levels` directly on every prop change — this is exactly that: one settled
90
+ * frame, no interpolation, still fully legible. (A live OS/browser
91
+ * reduced-motion toggle degrades every OTHER story to this same behavior;
92
+ * this story documents what that degradation looks like without depending on
93
+ * the test environment's motion setting.)
94
+ */
95
+ export const ReducedMotionFrame: Story = {
96
+ args: { levels: SPEECH_LEVELS },
97
+ parameters: {
98
+ docs: {
99
+ description: {
100
+ story:
101
+ "What every story above renders once `prefers-reduced-motion` is on: the current levels, drawn once, with no interpolation between updates.",
102
+ },
103
+ },
104
+ },
105
+ };
106
+
107
+ /**
108
+ * A simulated live session — a local interval stands in for a parent's own
109
+ * `requestAnimationFrame` + analyser loop, updating `levels` continuously.
110
+ */
111
+ function LiveDemo() {
112
+ const [levels, setLevels] = useState(SPEECH_LEVELS);
113
+
114
+ useEffect(() => {
115
+ const id = window.setInterval(() => {
116
+ setLevels((prev) => prev.map(() => Math.random() * 0.8 + 0.05));
117
+ }, 120);
118
+ return () => window.clearInterval(id);
119
+ }, []);
120
+
121
+ return <AudioVisualizer levels={levels} />;
122
+ }
123
+
124
+ export const Live: Story = {
125
+ render: () => <LiveDemo />,
126
+ };