@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
|
@@ -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
|
+
);
|
package/src/canvas.stories.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
package/src/chat.stories.tsx
CHANGED
|
@@ -89,9 +89,23 @@ function ChatExample() {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
const meta = {
|
|
92
|
-
title: "
|
|
92
|
+
title: "Patterns/Scenarios/Chat",
|
|
93
93
|
component: ChatExample,
|
|
94
|
-
parameters: {
|
|
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: {
|
|
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;
|