@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.
- package/README.md +61 -11
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1109 -202
- package/dist/index.js +4867 -2878
- package/dist/index.js.map +1 -1
- package/package.json +31 -16
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +21 -1
- package/src/_streamdown-safety.ts +170 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +28 -14
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +15 -12
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +263 -3
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +2 -1
- package/src/conversation.stories.tsx +63 -2
- package/src/conversation.test.tsx +13 -0
- package/src/conversation.tsx +20 -2
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +188 -0
- package/src/diff-view.tsx +642 -0
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +232 -1
- package/src/markdown-view.tsx +150 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +29 -1
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +176 -0
- package/src/message.tsx +90 -4
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
- package/src/model-provider-logo.tsx +149 -0
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- package/src/reasoning.tsx +27 -13
- package/src/sandbox.stories.tsx +9 -1
- package/src/schema-display.tsx +5 -2
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -1
- package/src/stack-trace.tsx +6 -2
- package/src/streamdown-i18n.test.tsx +1 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +67 -53
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.tsx +15 -8
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/web-preview.tsx +8 -3
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.tsx +0 -225
- package/src/terminal.tsx +0 -244
package/src/audio-player.tsx
CHANGED
|
@@ -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
|
|
7
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
42
|
-
<
|
|
43
|
-
|
|
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 =
|
|
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
|
-
<
|
|
74
|
-
<
|
|
75
|
-
|
|
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 =
|
|
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
|
-
<
|
|
86
|
-
<
|
|
87
|
-
|
|
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
|
|
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
|
-
<
|
|
98
|
-
<
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
110
|
-
<
|
|
111
|
-
|
|
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 =
|
|
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
|
-
<
|
|
122
|
-
<
|
|
123
|
-
|
|
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 =
|
|
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
|
-
<
|
|
132
|
-
<
|
|
133
|
-
|
|
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 =
|
|
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
|
-
<
|
|
144
|
-
<
|
|
145
|
-
|
|
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 =
|
|
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
|
-
<
|
|
156
|
-
<
|
|
157
|
-
|
|
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 =
|
|
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
|
-
<
|
|
168
|
-
<
|
|
169
|
-
|
|
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
|
+
};
|