@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
@@ -0,0 +1,367 @@
1
+ "use client";
2
+
3
+ import { useReducedMotion } from "@elabs-ai/components-tokens";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+ import { useLocale } from "@elabs-ai/components-ui";
6
+ import type { ComponentProps, ReactNode } from "react";
7
+ import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
8
+
9
+ /**
10
+ * brand-ui is a presentation layer (D5, docs/DECISIONS.md §D5): this component
11
+ * never calls `getUserMedia`, never owns an `AudioContext`, and starts nothing
12
+ * on mount. It draws whatever `levels` a parent — who already has a
13
+ * `MediaStream`/`AnalyserNode` — passes it, frame by frame. The analyser
14
+ * plumbing, when a consumer genuinely wants it, is the separate, opt-in
15
+ * `useAudioLevel` export in `./use-audio-level` — never something this
16
+ * component reaches for itself. See issue #21.
17
+ */
18
+
19
+ export type AudioVisualizerVariant = "bars" | "wave";
20
+ export type AudioVisualizerLevelState = "idle" | "silent" | "active";
21
+
22
+ /**
23
+ * Localized status announced when the discretized level state changes, one
24
+ * key per `AudioVisualizerLevelState`. Typed as a `Record` (not a bare
25
+ * template string) so a state value added later without a matching key here
26
+ * fails to typecheck — same convention as `Persona`'s `PERSONA_STATE_KEYS`.
27
+ */
28
+ const AUDIO_VISUALIZER_STATE_KEYS: Record<AudioVisualizerLevelState, string> = {
29
+ active: "ai.audioVisualizer.active",
30
+ idle: "ai.audioVisualizer.idle",
31
+ silent: "ai.audioVisualizer.silent",
32
+ };
33
+
34
+ // Fixed internal render resolution — the canvas is stretched to its CSS box
35
+ // (see the className below), so this is a rendering-quality knob, not a
36
+ // layout one. Picking a fixed size up front means the draw loop never has to
37
+ // measure the DOM (no `getBoundingClientRect`/`offsetHeight` in the hot path).
38
+ const CANVAS_WIDTH = 320;
39
+ const CANVAS_HEIGHT = 64;
40
+ const DEFAULT_BAR_COUNT = 32;
41
+ const DEFAULT_SENSITIVITY = 1;
42
+ const DEFAULT_SILENCE_THRESHOLD = 0.04;
43
+ /**
44
+ * Fraction of the remaining distance to the target level closed per animation
45
+ * frame — a fixed-cost lerp, never a growing history buffer, so the per-frame
46
+ * cost stays O(barCount) no matter how long the session has run.
47
+ */
48
+ const SMOOTHING_FACTOR = 0.35;
49
+ /** Once every displayed level is within this of its target, the smoothing has
50
+ * visually converged — stop scheduling more animation frames rather than
51
+ * looping forever on a static input. */
52
+ const CONVERGENCE_EPSILON = 0.002;
53
+ /**
54
+ * Hysteresis margin (as a fraction of `silenceThreshold`) around the active/
55
+ * silent boundary. A live signal hovering right at the threshold can cross it
56
+ * on consecutive samples; without a dead zone, every such graze would flip
57
+ * the announced `role="status"` text, flooding assistive tech with
58
+ * alternating "active"/"no input" announcements. A clear crossing (past the
59
+ * margin on either side) still flips immediately — only a graze inside the
60
+ * dead zone holds the previous state.
61
+ */
62
+ const HYSTERESIS_RATIO = 0.25;
63
+
64
+ const clamp01 = (value: number) => Math.min(1, Math.max(0, value));
65
+
66
+ /** A finite, rounded, >=2 bar count safe to use as an array length — an
67
+ * unsanitized `barCount` (NaN, Infinity, a fractional value from a
68
+ * misconfigured/computed prop) would otherwise throw `RangeError: Invalid
69
+ * array length` the moment it reaches `new Array(count)`. */
70
+ function normalizeBarCount(value: number): number {
71
+ if (!Number.isFinite(value)) return DEFAULT_BAR_COUNT;
72
+ return Math.max(2, Math.round(value));
73
+ }
74
+
75
+ /** True once every displayed level is within `CONVERGENCE_EPSILON` of its
76
+ * target — i.e. the smoothing animation has nothing left to visibly close. */
77
+ function hasConverged(display: number[], target: number[]): boolean {
78
+ for (let i = 0; i < target.length; i += 1) {
79
+ if (Math.abs((display[i] ?? 0) - (target[i] ?? 0)) > CONVERGENCE_EPSILON) return false;
80
+ }
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * The discretized level bucket, with hysteresis around `silenceThreshold` —
86
+ * see `HYSTERESIS_RATIO`. `previous` is only consulted inside the dead zone;
87
+ * a clear reading on either side is decisive regardless of history.
88
+ */
89
+ function computeLevelBucket(
90
+ average: number,
91
+ loading: boolean,
92
+ silenceThreshold: number,
93
+ previous: AudioVisualizerLevelState,
94
+ ): AudioVisualizerLevelState {
95
+ if (loading) return "idle";
96
+ const margin = silenceThreshold * HYSTERESIS_RATIO;
97
+ if (average >= silenceThreshold + margin) return "active";
98
+ if (average < silenceThreshold - margin) return "silent";
99
+ // Inside the dead zone: hold the previous bucket. `previous` starts as
100
+ // "idle" (mount, or just-exited `loading`), which isn't itself a valid
101
+ // active/silent reading to hold onto — fall back to the plain threshold
102
+ // check for that one transition only.
103
+ if (previous === "idle") return average >= silenceThreshold ? "active" : "silent";
104
+ return previous;
105
+ }
106
+
107
+ /** Nearest-neighbor resample of an arbitrary-length sample array to `count`
108
+ * entries, applying `sensitivity` and clamping to [0, 1]. Never allocates
109
+ * more than `count` numbers, independent of `levels.length`. */
110
+ function resampleLevels(levels: number[], count: number, sensitivity: number): number[] {
111
+ if (levels.length === 0) {
112
+ return new Array(count).fill(0);
113
+ }
114
+ const out = new Array<number>(count);
115
+ for (let i = 0; i < count; i += 1) {
116
+ const sourceIndex = Math.min(levels.length - 1, Math.floor((i / count) * levels.length));
117
+ out[i] = clamp01((levels[sourceIndex] ?? 0) * sensitivity);
118
+ }
119
+ return out;
120
+ }
121
+
122
+ function drawBars(ctx: CanvasRenderingContext2D, levels: number[], color: string) {
123
+ const { width, height } = ctx.canvas;
124
+ ctx.clearRect(0, 0, width, height);
125
+ const n = levels.length;
126
+ if (n === 0) return;
127
+ const gap = 2;
128
+ const barWidth = Math.max(1, (width - gap * (n - 1)) / n);
129
+ ctx.fillStyle = color;
130
+ for (let i = 0; i < n; i += 1) {
131
+ const level = levels[i] ?? 0;
132
+ const barHeight = Math.max(2, level * height);
133
+ const x = i * (barWidth + gap);
134
+ ctx.fillRect(x, height - barHeight, barWidth, barHeight);
135
+ }
136
+ }
137
+
138
+ /** Draws the CURRENT sample snapshot as a filled envelope (no history/scroll —
139
+ * see the reduced-motion note on `AudioVisualizer` for why this shape never
140
+ * accumulates state over time). */
141
+ function drawWave(ctx: CanvasRenderingContext2D, levels: number[], color: string) {
142
+ const { width, height } = ctx.canvas;
143
+ ctx.clearRect(0, 0, width, height);
144
+ const n = levels.length;
145
+ if (n < 2) return;
146
+ const midY = height / 2;
147
+ ctx.fillStyle = color;
148
+ ctx.beginPath();
149
+ for (let i = 0; i < n; i += 1) {
150
+ const x = (i / (n - 1)) * width;
151
+ const y = midY - clamp01(levels[i] ?? 0) * midY;
152
+ if (i === 0) ctx.moveTo(x, y);
153
+ else ctx.lineTo(x, y);
154
+ }
155
+ for (let i = n - 1; i >= 0; i -= 1) {
156
+ const x = (i / (n - 1)) * width;
157
+ const y = midY + clamp01(levels[i] ?? 0) * midY;
158
+ ctx.lineTo(x, y);
159
+ }
160
+ ctx.closePath();
161
+ ctx.fill();
162
+ }
163
+
164
+ /** Resolves the bar/wave fill colour from tokens at draw time. `getComputedStyle`
165
+ * resolves a CSS custom property's VALUE — unlike `getBoundingClientRect`/
166
+ * `offsetHeight`, it never forces layout, so this is not a "layout read". */
167
+ function resolveFillColor(canvas: HTMLCanvasElement): string {
168
+ const styles = getComputedStyle(canvas);
169
+ const token =
170
+ styles.getPropertyValue("--color-primary").trim() ||
171
+ styles.getPropertyValue("--primary").trim();
172
+ return token || styles.color || "currentColor";
173
+ }
174
+
175
+ export interface AudioVisualizerProps extends Omit<ComponentProps<"div">, "children"> {
176
+ /**
177
+ * Normalized 0–1 amplitude samples for the CURRENT frame — one per bar (or
178
+ * per sample point for `variant="wave"`). Update this from your own
179
+ * analyser polling loop (or the opt-in `useAudioLevel` hook) on every frame
180
+ * you want reflected; the component never touches the microphone itself.
181
+ * Any length works — samples are resampled to `barCount`.
182
+ */
183
+ levels?: number[];
184
+ /** No stream connected yet — renders the idle baseline instead of `levels`. */
185
+ loading?: boolean;
186
+ /** `"bars"` (default) — a bar chart. `"wave"` — a filled envelope through the
187
+ * current samples. Both encode level as HEIGHT, never as hue alone. */
188
+ variant?: AudioVisualizerVariant;
189
+ /** Multiplies each level before clamping to [0, 1] — turn up for a quiet input. */
190
+ sensitivity?: number;
191
+ /** Number of bars/points drawn. */
192
+ barCount?: number;
193
+ /** Average level, 0–1, at/above which the status text reads "active". */
194
+ silenceThreshold?: number;
195
+ /**
196
+ * Visually-hidden text announced when the discretized level state changes.
197
+ * Defaults to a localized label ("No input detected" / "Microphone
198
+ * active" / …). Pass `null` when the consuming surface already renders its
199
+ * own live region, so assistive tech is not told twice.
200
+ */
201
+ statusLabel?: ReactNode | null;
202
+ }
203
+
204
+ /**
205
+ * Live mic-level / waveform meter. Canvas-drawn (one element, no per-bar DOM,
206
+ * so a `levels` prop updating every frame never costs React a 32-element
207
+ * reconciliation) and driven entirely by the `levels` prop — see the module
208
+ * doc comment for the presentation-layer boundary this enforces.
209
+ *
210
+ * The canvas is decorative (`aria-hidden`); the level itself is announced
211
+ * through a throttled `role="status"` text alternative — see `statusLabel`.
212
+ *
213
+ * Reduced motion: under `prefers-reduced-motion` (or while `loading`) the
214
+ * component skips its internal smoothing animation entirely and paints the
215
+ * raw `levels` directly on every prop change — a static bar chart of the
216
+ * current levels, never a freeze mid-interpolation.
217
+ */
218
+ export const AudioVisualizer = forwardRef<HTMLDivElement, AudioVisualizerProps>(
219
+ function AudioVisualizer(
220
+ {
221
+ className,
222
+ levels = [],
223
+ loading = false,
224
+ variant = "bars",
225
+ sensitivity = DEFAULT_SENSITIVITY,
226
+ barCount = DEFAULT_BAR_COUNT,
227
+ silenceThreshold = DEFAULT_SILENCE_THRESHOLD,
228
+ statusLabel,
229
+ ...props
230
+ },
231
+ ref,
232
+ ) {
233
+ const { t } = useLocale();
234
+ const prefersReducedMotion = useReducedMotion();
235
+ const canvasRef = useRef<HTMLCanvasElement>(null);
236
+ const displayLevelsRef = useRef<number[]>([]);
237
+ const rafRef = useRef<number | null>(null);
238
+ const bucketRef = useRef<AudioVisualizerLevelState>("idle");
239
+ const [levelState, setLevelState] = useState<AudioVisualizerLevelState>("idle");
240
+ // Bumped whenever `data-theme` actually changes on the document — see the
241
+ // MutationObserver effect below. `resolveFillColor` cannot read a token
242
+ // value that isn't there yet: a theme-setting provider (e.g.
243
+ // `ThemeProvider`) applies `data-theme` in its OWN mount effect, and React
244
+ // fires a CHILD's effects before its parent's, so this component's paint
245
+ // effect otherwise runs first and reads whatever the un-themed `:root`
246
+ // fallback resolves to. Re-running the paint once the attribute lands
247
+ // (rather than only on the next animation frame, which the `loading`/
248
+ // reduced-motion path never schedules) is what fixes both the first paint
249
+ // AND a theme change after mount.
250
+ const [themeRevision, setThemeRevision] = useState(0);
251
+
252
+ const sampleCount = normalizeBarCount(barCount);
253
+
254
+ const targetLevels = useMemo(
255
+ () => resampleLevels(loading ? [] : levels, sampleCount, sensitivity),
256
+ [levels, loading, sampleCount, sensitivity],
257
+ );
258
+
259
+ useEffect(() => {
260
+ const canvas = canvasRef.current;
261
+ if (!canvas) return undefined;
262
+ const ctx = canvas.getContext("2d");
263
+
264
+ // The announced status always reflects the CURRENT target level,
265
+ // synchronously and independent of the smoothing animation below — a
266
+ // screen-reader user is never a lerp-frame behind the real signal, even
267
+ // though the visual bars/wave may still be easing toward it.
268
+ const average =
269
+ targetLevels.reduce((sum, value) => sum + value, 0) / (targetLevels.length || 1);
270
+ const bucket = computeLevelBucket(average, loading, silenceThreshold, bucketRef.current);
271
+ if (bucket !== bucketRef.current) {
272
+ bucketRef.current = bucket;
273
+ setLevelState(bucket);
274
+ }
275
+
276
+ const paint = (paintLevels: number[]) => {
277
+ if (!ctx) return;
278
+ const color = resolveFillColor(canvas);
279
+ if (variant === "wave") drawWave(ctx, paintLevels, color);
280
+ else drawBars(ctx, paintLevels, color);
281
+ };
282
+
283
+ if (prefersReducedMotion || loading) {
284
+ displayLevelsRef.current = targetLevels;
285
+ paint(targetLevels);
286
+ return undefined;
287
+ }
288
+
289
+ // Seed (or reset, on a `barCount` change) the smoothing buffer and paint
290
+ // this frame SYNCHRONOUSLY — the canvas and status text reflect the
291
+ // current `levels` immediately on mount/update, rather than waiting for
292
+ // the first `requestAnimationFrame` callback. The rAF loop below only
293
+ // has to run to carry the smoothing motion forward from here.
294
+ if (displayLevelsRef.current.length !== targetLevels.length) {
295
+ displayLevelsRef.current = targetLevels.slice();
296
+ }
297
+ paint(displayLevelsRef.current);
298
+
299
+ const tick = () => {
300
+ const display = displayLevelsRef.current;
301
+ const next = display.map(
302
+ (value, i) => value + ((targetLevels[i] ?? 0) - value) * SMOOTHING_FACTOR,
303
+ );
304
+ displayLevelsRef.current = next;
305
+ paint(next);
306
+ // Stop rescheduling once the smoothing has visually converged — the
307
+ // `targetLevels` dependency above restarts the loop the moment a real
308
+ // prop change gives it somewhere new to go.
309
+ if (hasConverged(next, targetLevels)) return;
310
+ rafRef.current = requestAnimationFrame(tick);
311
+ };
312
+
313
+ rafRef.current = requestAnimationFrame(tick);
314
+ return () => {
315
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
316
+ rafRef.current = null;
317
+ };
318
+ // `themeRevision` is the trigger; `resolveFillColor` re-reads the token
319
+ // live, so the value itself never needs to be a dependency.
320
+ }, [targetLevels, variant, loading, prefersReducedMotion, silenceThreshold, themeRevision]);
321
+
322
+ // Track `data-theme` (whatever sets it — `ThemeProvider`'s own mount
323
+ // effect, a later `setTheme` call, or Storybook's theme decorator) and
324
+ // re-run the paint effect above once it actually changes. Same fix as
325
+ // `InteractiveTerminal`'s identical `data-theme` race.
326
+ useEffect(() => {
327
+ if (typeof document === "undefined") return undefined;
328
+ const observer = new MutationObserver(() => setThemeRevision((r) => r + 1));
329
+ observer.observe(document.documentElement, {
330
+ attributes: true,
331
+ attributeFilter: ["data-theme"],
332
+ });
333
+ return () => observer.disconnect();
334
+ }, []);
335
+
336
+ const label =
337
+ statusLabel === null ? null : (statusLabel ?? t(AUDIO_VISUALIZER_STATE_KEYS[levelState]));
338
+
339
+ return (
340
+ <div
341
+ className={cn("flex flex-col gap-1", className)}
342
+ data-slot="audio-visualizer"
343
+ ref={ref}
344
+ {...props}
345
+ >
346
+ <canvas
347
+ aria-hidden="true"
348
+ className="h-16 w-full rounded-md bg-muted"
349
+ data-slot="audio-visualizer-canvas"
350
+ height={CANVAS_HEIGHT}
351
+ ref={canvasRef}
352
+ width={CANVAS_WIDTH}
353
+ />
354
+ {label !== null && (
355
+ <span
356
+ className="sr-only"
357
+ data-slot="audio-visualizer-status"
358
+ role="status"
359
+ aria-live="polite"
360
+ >
361
+ {label}
362
+ </span>
363
+ )}
364
+ </div>
365
+ );
366
+ },
367
+ );
@@ -1,6 +1,9 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { Handle, Position } from "@xyflow/react";
2
3
  import { expect, waitFor, within } from "storybook/test";
3
4
 
5
+ import { Card } from "@elabs-ai/components-ui";
6
+
4
7
  import { Canvas } from "./canvas";
5
8
  import { Controls } from "./controls";
6
9
  import { Edge } from "./edge";
@@ -60,7 +63,23 @@ const StepNode = ({ data }: { data: StepData }) => (
60
63
  );
61
64
 
62
65
  // React Flow requires stable `nodeTypes` / `edgeTypes` identities.
63
- const nodeTypes = { step: StepNode };
66
+ /**
67
+ * A consumer-authored node type with its handles on the TOP and BOTTOM, which
68
+ * `Canvas` fully supports — `nodeTypes` is an open prop and the shipped `Node`
69
+ * (left/right handles) is a convenience, not a constraint.
70
+ */
71
+ const VerticalStepNode = ({ data }: { data: StepData }) => (
72
+ <Card className="w-sm gap-0 rounded-md p-0">
73
+ <Handle position={Position.Top} type="target" />
74
+ <Handle position={Position.Bottom} type="source" />
75
+ <NodeHeader>
76
+ <NodeTitle>{data.title}</NodeTitle>
77
+ <NodeDescription>{data.description}</NodeDescription>
78
+ </NodeHeader>
79
+ </Card>
80
+ );
81
+
82
+ const nodeTypes = { step: StepNode, vertical: VerticalStepNode };
64
83
  const edgeTypes = { animated: Edge.Animated, temporary: Edge.Temporary };
65
84
 
66
85
  const nodes = [
@@ -105,6 +124,78 @@ const edges = [
105
124
  { id: "plan-draft", source: "plan", target: "draft", type: "temporary" },
106
125
  ];
107
126
 
127
+ /**
128
+ * Screen-space start/end of an edge path, through the path's own `getScreenCTM()`
129
+ * so the canvas pan/zoom transform is accounted for. Deliberately a local copy
130
+ * rather than an import of `@elabs-ai/components-flow`'s equivalent: the two
131
+ * canvases are layer-2 leaves and must never import each other (ADR 0018).
132
+ */
133
+ function edgeEndpoints(path: SVGPathElement) {
134
+ const ctm = path.getScreenCTM();
135
+ const svg = path.ownerSVGElement;
136
+ if (!ctm || !svg) throw new Error("edge path is not rendered inside a positioned <svg>");
137
+ const at = (length: number) => {
138
+ const local = path.getPointAtLength(length);
139
+ const point = svg.createSVGPoint();
140
+ point.x = local.x;
141
+ point.y = local.y;
142
+ const screen = point.matrixTransform(ctm);
143
+ return { x: screen.x, y: screen.y };
144
+ };
145
+ return [at(0), at(path.getTotalLength())] as const;
146
+ }
147
+
148
+ /**
149
+ * One line per edge endpoint that lands outside every node, naming the distance.
150
+ * An endpoint on a node — on its handle, or on its border where the node exposes
151
+ * no handle for that direction — is the passing state; an endpoint stranded in
152
+ * open canvas is the defect this locks.
153
+ *
154
+ * Node boxes are widened by the overhang of a rendered handle, because a handle
155
+ * is centred on the node border and its outer rim (where React Flow anchors an
156
+ * edge) therefore sits half a handle outside the node's own box.
157
+ */
158
+ function endpointsOffNodes(canvasElement: HTMLElement): string[] {
159
+ const boxes = Array.from(canvasElement.querySelectorAll<HTMLElement>(".react-flow__node")).map(
160
+ (node) => node.getBoundingClientRect(),
161
+ );
162
+ if (!boxes.length) return ["no nodes rendered"];
163
+ const overhang = Math.max(
164
+ 0,
165
+ ...Array.from(canvasElement.querySelectorAll<HTMLElement>(".react-flow__handle")).map(
166
+ (handle) => {
167
+ const rect = handle.getBoundingClientRect();
168
+ return Math.max(rect.width, rect.height) / 2;
169
+ },
170
+ ),
171
+ );
172
+ // Plus a pixel of sub-pixel/rounding allowance.
173
+ const tolerance = overhang + 1;
174
+
175
+ const misses: string[] = [];
176
+ for (const path of Array.from(
177
+ canvasElement.querySelectorAll<SVGPathElement>("path.react-flow__edge-path"),
178
+ )) {
179
+ const edgeId = path.closest("[data-id]")?.getAttribute("data-id") ?? "?";
180
+ const [start, end] = edgeEndpoints(path);
181
+ for (const [label, point] of [
182
+ ["start", start],
183
+ ["end", end],
184
+ ] as const) {
185
+ const gap = Math.min(
186
+ ...boxes.map((box) =>
187
+ Math.hypot(
188
+ Math.max(box.left - point.x, 0, point.x - box.right),
189
+ Math.max(box.top - point.y, 0, point.y - box.bottom),
190
+ ),
191
+ ),
192
+ );
193
+ if (gap > tolerance) misses.push(`${edgeId}.${label} is ${gap.toFixed(1)}px off every node`);
194
+ }
195
+ }
196
+ return misses;
197
+ }
198
+
108
199
  export const Default: Story = {
109
200
  render: () => (
110
201
  <div className="h-[600px] w-full">
@@ -138,5 +229,63 @@ export const Default: Story = {
138
229
  );
139
230
  expect(canvasElement.querySelectorAll(".react-flow__edge-path").length).toBe(2);
140
231
  });
232
+
233
+ // Every edge must terminate on a node, not in open canvas.
234
+ await waitFor(() => {
235
+ expect(endpointsOffNodes(canvasElement)).toEqual([]);
236
+ });
237
+ },
238
+ };
239
+
240
+ /**
241
+ * A consumer node type whose handles sit on the TOP and BOTTOM rather than the
242
+ * left and right, joined by the shipped animated edge. The edge must terminate
243
+ * on those handles.
244
+ *
245
+ * Before the fix the edge looked for a handle at a hard-coded `Right`/`Left`
246
+ * position and, finding none, resolved the anchor to `[0, 0]` — so the line was
247
+ * drawn to the CANVAS ORIGIN, hundreds of pixels from either node. The same
248
+ * `[0, 0]` fallback fired on the frames before React Flow has measured its
249
+ * handles, which on this canvas is a real window because the engine arrives in a
250
+ * lazy chunk (ADR 0019).
251
+ */
252
+ export const VerticalHandles: Story = {
253
+ render: () => (
254
+ <div className="h-[520px] w-full">
255
+ <Canvas
256
+ defaultEdges={[{ id: "a-b", source: "a", target: "b", type: "animated" }]}
257
+ defaultNodes={[
258
+ {
259
+ id: "a",
260
+ type: "vertical",
261
+ // Deliberately clear of the flow origin: a `[0, 0]` anchor must be
262
+ // measurably off both nodes, or the assertion is vacuous.
263
+ position: { x: 320, y: 220 },
264
+ data: { title: "Plan", description: "Decide the steps", detail: "", status: "Done" },
265
+ },
266
+ {
267
+ id: "b",
268
+ type: "vertical",
269
+ position: { x: 360, y: 520 },
270
+ data: { title: "Answer", description: "Final answer", detail: "", status: "Done" },
271
+ },
272
+ ]}
273
+ edgeTypes={edgeTypes}
274
+ nodeTypes={nodeTypes}
275
+ />
276
+ </div>
277
+ ),
278
+ play: async ({ canvasElement }) => {
279
+ const canvas = within(canvasElement);
280
+ await waitFor(
281
+ async () => {
282
+ await expect(canvas.getByText("Answer")).toBeInTheDocument();
283
+ },
284
+ { timeout: 10000 },
285
+ );
286
+ await waitFor(() => {
287
+ expect(canvasElement.querySelectorAll("path.react-flow__edge-path").length).toBe(1);
288
+ expect(endpointsOffNodes(canvasElement)).toEqual([]);
289
+ });
141
290
  },
142
291
  };
@@ -106,9 +106,24 @@ function ChatShellExample({ variant = "card" }: { variant?: "card" | "bare" }) {
106
106
 
107
107
  const meta = {
108
108
  title: "AI/ChatShell",
109
- component: ChatShellExample,
110
- parameters: { layout: "fullscreen" },
111
- } satisfies Meta<typeof ChatShellExample>;
109
+ // `component` names the REAL exported component, so the docs/MCP surface reads
110
+ // ChatShell's own props instead of a story-local demo's (RM-005). `render`
111
+ // supplies the live transcript + composer a layout shell needs in order to
112
+ // show anything; `children` is required by ChatShellProps and comes from the
113
+ // render, so the arg below is only a placeholder to satisfy the type.
114
+ component: ChatShell,
115
+ args: { variant: "card", children: null },
116
+ render: ({ variant }) => <ChatShellExample variant={variant} />,
117
+ parameters: {
118
+ layout: "fullscreen",
119
+ docs: {
120
+ description: {
121
+ component:
122
+ 'The CHAT frame — header, transcript and composer regions in one layout. The console frame is `Terminal/TerminalConsole`, which goes further and makes every region inside it flush (ADR 0033); see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). `variant: "card"` draws its own bordered frame, `variant: "bare"` fills a container that is already bounded so the shell never draws a redundant second frame.',
123
+ },
124
+ },
125
+ },
126
+ } satisfies Meta<typeof ChatShell>;
112
127
  export default meta;
113
128
  type Story = StoryObj<typeof meta>;
114
129
 
@@ -89,9 +89,23 @@ function ChatExample() {
89
89
  }
90
90
 
91
91
  const meta = {
92
- title: "AI/Chat",
92
+ title: "Patterns/Scenarios/Chat",
93
93
  component: ChatExample,
94
- parameters: { layout: "fullscreen" },
94
+ parameters: {
95
+ layout: "fullscreen",
96
+ docs: {
97
+ description: {
98
+ component:
99
+ "A demo SCENARIO, not an importable component — the smallest end-to-end chat " +
100
+ "composed from the @elabs-ai/components-ai grammar: a scrolling " +
101
+ "[Conversation](?path=/story/ai-conversation--default) of " +
102
+ "[Messages](?path=/story/ai-message--presets) with reasoning and sources, plus a " +
103
+ "[Composer](?path=/story/ai-composer--default). Reach for " +
104
+ "[ChatShell](?path=/story/ai-chatshell--default) when you want that header + " +
105
+ "transcript + composer layout as one component instead of assembling it yourself.",
106
+ },
107
+ },
108
+ },
95
109
  } satisfies Meta<typeof ChatExample>;
96
110
  export default meta;
97
111
  type Story = StoryObj<typeof meta>;
@@ -3,7 +3,15 @@ import { CodeBlock } from "./code-block";
3
3
  const meta = {
4
4
  title: "AI/CodeBlock",
5
5
  component: CodeBlock,
6
- parameters: { layout: "padded" },
6
+ parameters: {
7
+ layout: "padded",
8
+ docs: {
9
+ description: {
10
+ component:
11
+ "Multi-line highlighted SOURCE: Shiki tokens themed from the `--code-*` tokens, an optional language picker and a copy button. A single-line copyable command is `AI/Snippet`, and a markdown DOCUMENT belongs in `AI/MarkdownView` rather than here — rendering prose as source is the defect that component was added to fix. See [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
12
+ },
13
+ },
14
+ },
7
15
  tags: ["autodocs"],
8
16
  } satisfies Meta<typeof CodeBlock>;
9
17
  export default meta;