@elabs-ai/components-ai 4.0.0 → 4.2.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 (180) hide show
  1. package/README.md +62 -12
  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 +1152 -218
  9. package/dist/index.js +5601 -3241
  10. package/dist/index.js.map +1 -1
  11. package/package.json +30 -15
  12. package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
  13. package/src/__contract__/chat-shell.contract.test.tsx +49 -0
  14. package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
  15. package/src/__contract__/image.contract.test.tsx +49 -0
  16. package/src/__contract__/markdown-view.contract.test.tsx +49 -0
  17. package/src/__contract__/message-feedback.contract.test.tsx +49 -0
  18. package/src/__contract__/message-form.contract.test.tsx +49 -0
  19. package/src/__contract__/message-table.contract.test.tsx +49 -0
  20. package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
  21. package/src/__contract__/persona.contract.test.tsx +49 -0
  22. package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
  23. package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
  24. package/src/_audio-player-media-chrome.tsx +102 -15
  25. package/src/_chat-shell-rail.tsx +2 -2
  26. package/src/_flow-boundary.tsx +68 -49
  27. package/src/_lazy-boundary-conformance.ts +38 -0
  28. package/src/_lazy-cjk.test.ts +43 -0
  29. package/src/_lazy-cjk.ts +73 -0
  30. package/src/_lazy-engine-boundary.tsx +61 -0
  31. package/src/_lazy-math.test.ts +63 -0
  32. package/src/_lazy-math.ts +90 -0
  33. package/src/_lazy-mermaid-absent.test.ts +53 -0
  34. package/src/_lazy-mermaid.test.ts +15 -0
  35. package/src/_lazy-mermaid.ts +24 -1
  36. package/src/_mermaid-error-panel.test.tsx +50 -0
  37. package/src/_mermaid-error-panel.tsx +66 -0
  38. package/src/_persona-rive.tsx +62 -10
  39. package/src/_streamdown-i18n.ts +94 -22
  40. package/src/_streamdown-safety.ts +170 -0
  41. package/src/_theme-scope-store.test.ts +83 -0
  42. package/src/_theme-scope-store.ts +103 -0
  43. package/src/agent-event.stories.tsx +97 -0
  44. package/src/agent-event.test.tsx +145 -0
  45. package/src/agent-event.tsx +187 -0
  46. package/src/agent-timeline.stories.tsx +9 -1
  47. package/src/agent.test.tsx +47 -0
  48. package/src/agent.tsx +43 -24
  49. package/src/agentic-workspace.stories.tsx +27 -20
  50. package/src/artifact.tsx +17 -14
  51. package/src/asset-preview.test.tsx +40 -0
  52. package/src/asset-preview.tsx +83 -12
  53. package/src/attachments.tsx +7 -4
  54. package/src/audio-player.test.tsx +78 -0
  55. package/src/audio-player.tsx +243 -56
  56. package/src/audio-visualizer.stories.tsx +126 -0
  57. package/src/audio-visualizer.test.tsx +438 -0
  58. package/src/audio-visualizer.tsx +367 -0
  59. package/src/canvas.stories.tsx +150 -1
  60. package/src/chat-shell.stories.tsx +18 -3
  61. package/src/chat.stories.tsx +16 -2
  62. package/src/code-block.stories.tsx +9 -1
  63. package/src/code-block.test.tsx +100 -1
  64. package/src/code-block.tsx +166 -103
  65. package/src/commit.tsx +30 -41
  66. package/src/composer.stories.tsx +372 -9
  67. package/src/composer.test.tsx +357 -6
  68. package/src/composer.tsx +217 -35
  69. package/src/confirmation.stories.tsx +72 -1
  70. package/src/confirmation.test.tsx +216 -2
  71. package/src/confirmation.tsx +264 -4
  72. package/src/context-panel.stories.tsx +9 -1
  73. package/src/context-panel.tsx +3 -2
  74. package/src/conversation.stories.tsx +86 -2
  75. package/src/conversation.test.tsx +66 -0
  76. package/src/conversation.tsx +52 -9
  77. package/src/diff-view.stories.tsx +196 -0
  78. package/src/diff-view.test.tsx +238 -0
  79. package/src/diff-view.tsx +697 -0
  80. package/src/environment-variables.tsx +20 -37
  81. package/src/file-tree.test.tsx +21 -0
  82. package/src/file-tree.tsx +12 -2
  83. package/src/gallery.tsx +4 -2
  84. package/src/index.ts +14 -4
  85. package/src/inline-citation.tsx +5 -5
  86. package/src/jsx-preview.stories.tsx +2 -2
  87. package/src/jsx-preview.tsx +151 -43
  88. package/src/markdown-view.stories.tsx +92 -1
  89. package/src/markdown-view.test.tsx +237 -2
  90. package/src/markdown-view.tsx +157 -6
  91. package/src/message-compare.stories.tsx +175 -0
  92. package/src/message-compare.test.tsx +207 -0
  93. package/src/message-compare.tsx +453 -0
  94. package/src/message-form.stories.tsx +65 -4
  95. package/src/message-form.test.tsx +8 -2
  96. package/src/message-form.tsx +15 -7
  97. package/src/message-table.stories.tsx +2 -2
  98. package/src/message-table.test.tsx +7 -0
  99. package/src/message-table.tsx +8 -4
  100. package/src/message.stories.tsx +9 -1
  101. package/src/message.test.tsx +247 -1
  102. package/src/message.tsx +125 -15
  103. package/src/microcopy.test.tsx +40 -0
  104. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  105. package/src/model-provider-logo.test.tsx +109 -0
  106. package/src/model-provider-logo.tsx +183 -0
  107. package/src/open-in-chat.tsx +50 -29
  108. package/src/package-info.tsx +12 -12
  109. package/src/permission-mode-select.stories.tsx +82 -0
  110. package/src/permission-mode-select.test.tsx +100 -0
  111. package/src/permission-mode-select.tsx +137 -0
  112. package/src/persona-missing-peer.test.tsx +54 -0
  113. package/src/persona.tsx +68 -22
  114. package/src/plan.stories.tsx +166 -0
  115. package/src/plan.test.tsx +267 -0
  116. package/src/plan.tsx +182 -20
  117. package/src/prompt-input-effort.stories.tsx +123 -0
  118. package/src/prompt-input-effort.test.tsx +83 -0
  119. package/src/prompt-input-effort.tsx +136 -0
  120. package/src/prompt-input-mode.stories.tsx +108 -0
  121. package/src/prompt-input-mode.test.tsx +99 -0
  122. package/src/prompt-input-mode.tsx +169 -0
  123. package/src/prompt-input-slash.stories.tsx +211 -0
  124. package/src/prompt-input-slash.test.tsx +262 -0
  125. package/src/prompt-input-slash.tsx +541 -0
  126. package/src/prompt-input.stories.tsx +2 -2
  127. package/src/prompt-input.test.tsx +67 -1
  128. package/src/prompt-input.tsx +42 -4
  129. package/src/queue.tsx +4 -4
  130. package/src/reasoning.tsx +42 -17
  131. package/src/sandbox.stories.tsx +9 -1
  132. package/src/sandbox.tsx +3 -3
  133. package/src/schema-display.test.tsx +56 -0
  134. package/src/schema-display.tsx +71 -37
  135. package/src/selection-toolbar.stories.tsx +9 -1
  136. package/src/session-header.stories.tsx +128 -0
  137. package/src/session-header.test.tsx +138 -0
  138. package/src/session-header.tsx +243 -0
  139. package/src/session-status-bar.stories.tsx +73 -0
  140. package/src/session-status-bar.test.tsx +94 -0
  141. package/src/session-status-bar.tsx +165 -0
  142. package/src/snippet.stories.tsx +9 -1
  143. package/src/snippet.test.tsx +6 -2
  144. package/src/snippet.tsx +14 -34
  145. package/src/speech-input.test.tsx +109 -0
  146. package/src/speech-input.tsx +31 -3
  147. package/src/stack-trace.tsx +24 -36
  148. package/src/streamdown-i18n.test.tsx +24 -1
  149. package/src/task.stories.tsx +13 -3
  150. package/src/templates-ai-assistant.stories.tsx +21 -1
  151. package/src/test-results.tsx +47 -27
  152. package/src/token-usage.stories.tsx +24 -0
  153. package/src/token-usage.test.tsx +92 -0
  154. package/src/{context.tsx → token-usage.tsx} +73 -59
  155. package/src/tool-result-card.stories.tsx +9 -1
  156. package/src/tool.stories.tsx +13 -3
  157. package/src/tool.test.tsx +65 -0
  158. package/src/tool.tsx +73 -30
  159. package/src/transcription.tsx +1 -1
  160. package/src/turn-status.stories.tsx +124 -0
  161. package/src/turn-status.test.tsx +74 -0
  162. package/src/turn-status.tsx +174 -0
  163. package/src/use-audio-level.ts +104 -0
  164. package/src/voice-selector.tsx +5 -5
  165. package/src/web-preview.test.tsx +51 -1
  166. package/src/web-preview.tsx +46 -10
  167. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  168. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  169. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  170. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  171. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  172. package/src/_interactive-terminal-xterm.ts +0 -32
  173. package/src/blocks-ai-composer.stories.tsx +0 -83
  174. package/src/context.stories.tsx +0 -16
  175. package/src/interactive-terminal.stories.tsx +0 -165
  176. package/src/interactive-terminal.test.tsx +0 -448
  177. package/src/interactive-terminal.tsx +0 -444
  178. package/src/model-selector.test.tsx +0 -55
  179. package/src/model-selector.tsx +0 -225
  180. package/src/terminal.tsx +0 -244
@@ -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
+ };
@@ -0,0 +1,438 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { ThemeProvider, useTheme } from "@elabs-ai/components-tokens";
4
+
5
+ import { AudioVisualizer } from "./audio-visualizer";
6
+ import { useAudioLevel } from "./use-audio-level";
7
+
8
+ // AudioVisualizer draws to a 2D canvas context, which jsdom does not
9
+ // implement (it returns `null`, same as every other canvas-drawing component
10
+ // in this repo — see packages/viewer/src/adapters/pdf/pdf-adapter.test.tsx).
11
+ // A bare object stands in so drawing calls resolve without throwing; nothing
12
+ // here asserts on pixels, only on the component's own state (the announced
13
+ // status text) and its refusal to crash without a real canvas.
14
+ beforeEach(() => {
15
+ // Re-spied every test: `afterEach` below calls `vi.restoreAllMocks()`,
16
+ // which restores the REAL jsdom implementation (not just mock state), so a
17
+ // `beforeAll`-only spy stops intercepting after the first test.
18
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
19
+ clearRect: vi.fn(),
20
+ fillRect: vi.fn(),
21
+ beginPath: vi.fn(),
22
+ moveTo: vi.fn(),
23
+ lineTo: vi.fn(),
24
+ closePath: vi.fn(),
25
+ fill: vi.fn(),
26
+ canvas: { width: 320, height: 64 },
27
+ } as unknown as CanvasRenderingContext2D);
28
+ });
29
+
30
+ afterEach(() => {
31
+ cleanup();
32
+ vi.restoreAllMocks();
33
+ });
34
+
35
+ const SPEECH_LEVELS = Array.from({ length: 8 }, (_, i) => (i % 2 === 0 ? 0.8 : 0.6));
36
+ const QUIET_LEVELS = Array.from({ length: 8 }, () => 0.01);
37
+
38
+ describe("AudioVisualizer — presentation-layer boundary (D5, issue #21)", () => {
39
+ it("renders with no microphone and no AudioContext available", () => {
40
+ const originalAudioContext = (window as { AudioContext?: unknown }).AudioContext;
41
+ // Deliberately deleting to simulate an environment with no Web Audio API.
42
+ // (No ESLint rule in this repo's config flags a dot-notation `delete` — the
43
+ // property is restored below regardless.)
44
+ delete (window as { AudioContext?: unknown }).AudioContext;
45
+ expect(() => render(<AudioVisualizer levels={SPEECH_LEVELS} />)).not.toThrow();
46
+ (window as { AudioContext?: unknown }).AudioContext = originalAudioContext;
47
+ });
48
+
49
+ it("never touches getUserMedia", () => {
50
+ const getUserMedia = vi.fn();
51
+ Object.defineProperty(navigator, "mediaDevices", {
52
+ configurable: true,
53
+ value: { getUserMedia },
54
+ });
55
+ render(<AudioVisualizer levels={SPEECH_LEVELS} loading />);
56
+ expect(getUserMedia).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it("renders a canvas that is aria-hidden — the level is announced as text, not via the graphic", () => {
60
+ const { container } = render(<AudioVisualizer levels={SPEECH_LEVELS} />);
61
+ const canvas = container.querySelector('[data-slot="audio-visualizer-canvas"]');
62
+ expect(canvas).not.toBeNull();
63
+ expect(canvas).toHaveAttribute("aria-hidden", "true");
64
+ });
65
+
66
+ it("carries the root data-slot", () => {
67
+ const { container } = render(<AudioVisualizer levels={SPEECH_LEVELS} />);
68
+ expect(container.querySelector('[data-slot="audio-visualizer"]')).not.toBeNull();
69
+ });
70
+ });
71
+
72
+ describe("AudioVisualizer — level announced to assistive tech", () => {
73
+ it('announces "Microphone active" once the average level clears the silence threshold', () => {
74
+ render(<AudioVisualizer levels={SPEECH_LEVELS} />);
75
+ expect(screen.getByRole("status")).toHaveTextContent("Microphone active");
76
+ });
77
+
78
+ it('announces "No input detected" for a connected but quiet signal', () => {
79
+ render(<AudioVisualizer levels={QUIET_LEVELS} />);
80
+ expect(screen.getByRole("status")).toHaveTextContent("No input detected");
81
+ });
82
+
83
+ it('announces "Microphone not connected" while loading, ignoring any stale levels', () => {
84
+ render(<AudioVisualizer levels={SPEECH_LEVELS} loading />);
85
+ expect(screen.getByRole("status")).toHaveTextContent("Microphone not connected");
86
+ });
87
+
88
+ it("updates the SAME live region rather than remounting it when the state changes", () => {
89
+ const { rerender } = render(<AudioVisualizer levels={QUIET_LEVELS} />);
90
+ const region = screen.getByRole("status");
91
+ expect(region).toHaveTextContent("No input detected");
92
+
93
+ rerender(<AudioVisualizer levels={SPEECH_LEVELS} />);
94
+ expect(screen.getByRole("status")).toBe(region);
95
+ expect(region).toHaveTextContent("Microphone active");
96
+ });
97
+
98
+ it("renders no status region when statusLabel is explicitly null", () => {
99
+ render(<AudioVisualizer levels={SPEECH_LEVELS} statusLabel={null} />);
100
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
101
+ });
102
+
103
+ it("renders a caller-supplied statusLabel instead of the default", () => {
104
+ render(<AudioVisualizer levels={SPEECH_LEVELS} statusLabel="Listening…" />);
105
+ expect(screen.getByRole("status")).toHaveTextContent("Listening…");
106
+ });
107
+ });
108
+
109
+ describe("AudioVisualizer — barCount is normalized before it sizes any array (crash guard)", () => {
110
+ it("does not throw for a NaN barCount", () => {
111
+ expect(() => render(<AudioVisualizer levels={SPEECH_LEVELS} barCount={NaN} />)).not.toThrow();
112
+ });
113
+
114
+ it("does not throw for an Infinity barCount, and falls back to the default bar count", () => {
115
+ const fillRect = vi.fn();
116
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
117
+ clearRect: vi.fn(),
118
+ fillRect,
119
+ beginPath: vi.fn(),
120
+ moveTo: vi.fn(),
121
+ lineTo: vi.fn(),
122
+ closePath: vi.fn(),
123
+ fill: vi.fn(),
124
+ canvas: { width: 320, height: 64 },
125
+ } as unknown as CanvasRenderingContext2D);
126
+ expect(() =>
127
+ render(<AudioVisualizer levels={SPEECH_LEVELS} barCount={Infinity} />),
128
+ ).not.toThrow();
129
+ // One `fillRect` per bar (see `drawBars`) — the default bar count, since
130
+ // `Infinity` cannot size the sample array itself.
131
+ expect(fillRect).toHaveBeenCalledTimes(32);
132
+ });
133
+
134
+ it("rounds and clamps a fractional/too-small barCount to a safe integer >= 2", () => {
135
+ const fillRect = vi.fn();
136
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
137
+ clearRect: vi.fn(),
138
+ fillRect,
139
+ beginPath: vi.fn(),
140
+ moveTo: vi.fn(),
141
+ lineTo: vi.fn(),
142
+ closePath: vi.fn(),
143
+ fill: vi.fn(),
144
+ canvas: { width: 320, height: 64 },
145
+ } as unknown as CanvasRenderingContext2D);
146
+ // 0.4 alone would round-clamp to 2 under the OLD `Math.max(2, barCount)`
147
+ // too (2 > 0.4) — use a fractional count ABOVE 2 so only the fix's
148
+ // `Math.round` (not the pre-existing floor) makes this non-throwing.
149
+ render(<AudioVisualizer levels={SPEECH_LEVELS} barCount={5.7} />);
150
+ expect(fillRect).toHaveBeenCalledTimes(6);
151
+ });
152
+ });
153
+
154
+ describe("AudioVisualizer — status hysteresis around the silence threshold (no flapping)", () => {
155
+ // A uniform-value samples array resamples to that same constant, so the
156
+ // announced average is exactly the fill value — no need to reason about
157
+ // `resampleLevels`'s bucketing here. Values are chosen relative to the
158
+ // DEFAULT_SILENCE_THRESHOLD (0.04) and its ±25% hysteresis margin (0.01):
159
+ // clearly active (>=0.05), clearly silent (<0.03), and inside the dead
160
+ // zone [0.03, 0.05).
161
+ const ACTIVE_LEVELS = Array.from({ length: 8 }, () => 0.06);
162
+ const SILENT_LEVELS = Array.from({ length: 8 }, () => 0.01);
163
+ // Below the PLAIN threshold (0.04) but inside the dead zone — a naive
164
+ // `average >= threshold` check flips this to "silent", so this value locks
165
+ // the "coming from active, holds active" direction.
166
+ const DEAD_ZONE_BELOW_PLAIN_THRESHOLD = Array.from({ length: 8 }, () => 0.035);
167
+ // ABOVE the plain threshold but still inside the dead zone — a naive check
168
+ // flips this to "active", so this value locks the opposite, "coming from
169
+ // silent, holds silent" direction.
170
+ const DEAD_ZONE_ABOVE_PLAIN_THRESHOLD = Array.from({ length: 8 }, () => 0.045);
171
+
172
+ it("holds the 'active' status while the level dips into the dead zone, only flipping on a clear crossing", () => {
173
+ const { rerender } = render(<AudioVisualizer levels={ACTIVE_LEVELS} />);
174
+ expect(screen.getByRole("status")).toHaveTextContent("Microphone active");
175
+
176
+ rerender(<AudioVisualizer levels={DEAD_ZONE_BELOW_PLAIN_THRESHOLD} />);
177
+ // A graze inside the dead zone must NOT flip the announced status.
178
+ expect(screen.getByRole("status")).toHaveTextContent("Microphone active");
179
+
180
+ rerender(<AudioVisualizer levels={SILENT_LEVELS} />);
181
+ // A clear crossing past the margin still flips immediately.
182
+ expect(screen.getByRole("status")).toHaveTextContent("No input detected");
183
+ });
184
+
185
+ it("holds the 'silent' status while the level rises into the dead zone from below", () => {
186
+ const { rerender } = render(<AudioVisualizer levels={SILENT_LEVELS} />);
187
+ expect(screen.getByRole("status")).toHaveTextContent("No input detected");
188
+
189
+ rerender(<AudioVisualizer levels={DEAD_ZONE_ABOVE_PLAIN_THRESHOLD} />);
190
+ expect(screen.getByRole("status")).toHaveTextContent("No input detected");
191
+
192
+ rerender(<AudioVisualizer levels={ACTIVE_LEVELS} />);
193
+ expect(screen.getByRole("status")).toHaveTextContent("Microphone active");
194
+ });
195
+ });
196
+
197
+ describe("AudioVisualizer — reduced motion (colour is never the only channel either)", () => {
198
+ const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
199
+
200
+ function mockPrefersReducedMotion(matches: boolean) {
201
+ Object.defineProperty(window, "matchMedia", {
202
+ configurable: true,
203
+ value: vi.fn().mockImplementation((query: string) => ({
204
+ matches: query === REDUCED_MOTION_QUERY ? matches : false,
205
+ media: query,
206
+ addEventListener: vi.fn(),
207
+ removeEventListener: vi.fn(),
208
+ })),
209
+ });
210
+ }
211
+
212
+ it("settles into the non-animating reduced-motion path and paints the current levels", () => {
213
+ mockPrefersReducedMotion(true);
214
+ const raf = vi.spyOn(window, "requestAnimationFrame");
215
+ const caf = vi.spyOn(window, "cancelAnimationFrame");
216
+ render(<AudioVisualizer levels={SPEECH_LEVELS} />);
217
+ // `useReducedMotion` resolves the OS media query inside its own effect, so
218
+ // the very first render pass can transiently see `false` before that
219
+ // effect commits — the same one-render lag every consumer of the hook has
220
+ // (Shimmer included). Any frame scheduled during that transient window is
221
+ // cancelled before it can fire, so no animation is left running once
222
+ // effects settle; that is the actual contract, not "never called".
223
+ expect(caf.mock.calls.length).toBe(raf.mock.calls.length);
224
+ expect(screen.getByRole("status")).toHaveTextContent("Microphone active");
225
+ });
226
+
227
+ it("schedules a smoothing animation frame when motion is allowed", () => {
228
+ mockPrefersReducedMotion(false);
229
+ const raf = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(0);
230
+ render(<AudioVisualizer levels={SPEECH_LEVELS} />);
231
+ expect(raf).toHaveBeenCalled();
232
+ });
233
+
234
+ it("stops scheduling further animation frames once the smoothing has converged to the target — never loops forever on a settled signal", () => {
235
+ mockPrefersReducedMotion(false);
236
+ const callbacks: FrameRequestCallback[] = [];
237
+ let nextId = 1;
238
+ const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
239
+ callbacks.push(cb);
240
+ return nextId++;
241
+ });
242
+ vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
243
+
244
+ // Mount snaps the smoothing buffer straight to its target (see the
245
+ // component's own "seed/reset" comment), so a rerender with a genuinely
246
+ // DIFFERENT target is what actually gives the smoothing animation
247
+ // somewhere to go.
248
+ const { rerender } = render(<AudioVisualizer levels={QUIET_LEVELS} />);
249
+ rerender(<AudioVisualizer levels={SPEECH_LEVELS} />);
250
+
251
+ const callsBeforeFrames = raf.mock.calls.length;
252
+ // Manually pump up to 40 animation frames (real smoothing converges in
253
+ // ~14 at SMOOTHING_FACTOR=0.35 from this large a gap) by always invoking
254
+ // the MOST RECENTLY scheduled callback — mirroring how a browser would
255
+ // drive the same recursive `requestAnimationFrame(tick)` chain.
256
+ for (let i = 0; i < 40; i += 1) {
257
+ const cb = callbacks[callbacks.length - 1];
258
+ if (!cb) break;
259
+ act(() => {
260
+ cb(0);
261
+ });
262
+ }
263
+
264
+ const framesActuallyScheduled = raf.mock.calls.length - callsBeforeFrames;
265
+ // A never-converging loop would have scheduled all 40; the fix stops
266
+ // well before that once the displayed levels are within epsilon of target.
267
+ expect(framesActuallyScheduled).toBeGreaterThan(0);
268
+ expect(framesActuallyScheduled).toBeLessThan(40);
269
+ });
270
+ });
271
+
272
+ describe("AudioVisualizer — painted colour tracks the active theme, not a mount-time snapshot (F1)", () => {
273
+ // `resolveFillColor` reads `--color-primary`/`--primary` via
274
+ // `getComputedStyle(canvas)` — a value a REAL browser resolves by cascading
275
+ // down from wherever `data-theme` lives (normally `<html>`, which
276
+ // `ThemeProvider` writes in its own mount effect). jsdom implements neither
277
+ // the cascade nor custom-property inheritance (`getComputedStyle` only ever
278
+ // reflects an element's OWN inline style — verified against a live jsdom
279
+ // instance while investigating this bug), so this mock stands in for the
280
+ // browser's computed value while still exercising the REAL race: React
281
+ // runs a child's `useEffect` before its parent's, so `AudioVisualizer`'s
282
+ // paint effect (a child of `ThemeProvider`) fires before `ThemeProvider`'s
283
+ // own effect has written `data-theme` — reading whatever the un-themed
284
+ // `:root` fallback resolves to, exactly like the reported
285
+ // `rgb(57, 105, 217)`.
286
+ const ROOT_FALLBACK = "rgb(57, 105, 217)"; // :root's un-themed --primary
287
+ const THEME_PRIMARY: Record<string, string> = {
288
+ light: "rgb(10, 20, 30)",
289
+ dark: "rgb(200, 210, 220)",
290
+ };
291
+
292
+ let paintedColor: string | undefined;
293
+
294
+ beforeEach(() => {
295
+ paintedColor = undefined;
296
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(
297
+ function mockGetContext() {
298
+ const ctx = {
299
+ clearRect: vi.fn(),
300
+ fillRect: vi.fn(() => {
301
+ paintedColor = ctx.fillStyle;
302
+ }),
303
+ beginPath: vi.fn(),
304
+ moveTo: vi.fn(),
305
+ lineTo: vi.fn(),
306
+ closePath: vi.fn(),
307
+ fill: vi.fn(() => {
308
+ paintedColor = ctx.fillStyle;
309
+ }),
310
+ canvas: { width: 320, height: 64 },
311
+ fillStyle: "",
312
+ };
313
+ return ctx as unknown as CanvasRenderingContext2D;
314
+ },
315
+ );
316
+
317
+ vi.spyOn(window, "getComputedStyle").mockImplementation((..._args: unknown[]) => {
318
+ const theme = document.documentElement.getAttribute("data-theme");
319
+ const primary = (theme && THEME_PRIMARY[theme]) || ROOT_FALLBACK;
320
+ return {
321
+ getPropertyValue: (prop: string) =>
322
+ prop === "--color-primary" || prop === "--primary" ? primary : "",
323
+ color: "",
324
+ } as CSSStyleDeclaration;
325
+ });
326
+
327
+ // `ThemeProvider` reads `window.matchMedia` on mount (OS reduced-motion
328
+ // tracking). Re-defined here, not just relied on from the setup file's
329
+ // default stub, because a sibling `describe` above permanently replaces
330
+ // `window.matchMedia` with a `vi.fn()` — `vi.restoreAllMocks()` in that
331
+ // block's own `afterEach` then resets (not removes) that mock function,
332
+ // so it starts returning `undefined` for every test that runs after it in
333
+ // this file. This block must render `ThemeProvider` correctly regardless
334
+ // of what ran before it.
335
+ Object.defineProperty(window, "matchMedia", {
336
+ writable: true,
337
+ configurable: true,
338
+ value: vi.fn().mockImplementation((query: string) => ({
339
+ matches: false,
340
+ media: query,
341
+ addEventListener: vi.fn(),
342
+ removeEventListener: vi.fn(),
343
+ })),
344
+ });
345
+ });
346
+
347
+ afterEach(() => {
348
+ document.documentElement.removeAttribute("data-theme");
349
+ });
350
+
351
+ it("paints the active theme's --primary once ThemeProvider has applied data-theme — never the :root fallback", async () => {
352
+ render(
353
+ <ThemeProvider defaultTheme="dark" storageKey={null}>
354
+ <AudioVisualizer loading />
355
+ </ThemeProvider>,
356
+ );
357
+
358
+ // The attribute IS on <html> by the time render() returns...
359
+ expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
360
+ // ...but the fix's own correction (the MutationObserver callback, and the
361
+ // re-render it schedules) lands as a MICROTASK, same as it would in a
362
+ // real browser — flush it inside `act` before asserting the FINAL painted
363
+ // colour, not whatever was painted mid-race.
364
+ await act(async () => {
365
+ await new Promise((resolve) => setTimeout(resolve, 0));
366
+ });
367
+ expect(paintedColor).not.toBe(ROOT_FALLBACK);
368
+ expect(paintedColor).toBe(THEME_PRIMARY.dark);
369
+ });
370
+
371
+ it("repaints to track a theme change after mount, rather than keeping the colour painted at mount", async () => {
372
+ function ThemeToggleButton() {
373
+ const { setTheme } = useTheme();
374
+ return (
375
+ <button type="button" onClick={() => setTheme("light")}>
376
+ switch to light
377
+ </button>
378
+ );
379
+ }
380
+
381
+ render(
382
+ <ThemeProvider defaultTheme="dark" storageKey={null}>
383
+ <AudioVisualizer loading />
384
+ <ThemeToggleButton />
385
+ </ThemeProvider>,
386
+ );
387
+ await act(async () => {
388
+ await new Promise((resolve) => setTimeout(resolve, 0));
389
+ });
390
+ expect(paintedColor).toBe(THEME_PRIMARY.dark);
391
+
392
+ fireEvent.click(screen.getByRole("button", { name: "switch to light" }));
393
+ await act(async () => {
394
+ await new Promise((resolve) => setTimeout(resolve, 0));
395
+ });
396
+
397
+ expect(paintedColor).toBe(THEME_PRIMARY.light);
398
+ });
399
+ });
400
+
401
+ describe("useAudioLevel — never requests the microphone itself", () => {
402
+ it("returns empty levels and never calls getUserMedia when no stream is provided", () => {
403
+ const getUserMedia = vi.fn();
404
+ Object.defineProperty(navigator, "mediaDevices", {
405
+ configurable: true,
406
+ value: { getUserMedia },
407
+ });
408
+
409
+ let result: ReturnType<typeof useAudioLevel> | undefined;
410
+ function Probe() {
411
+ result = useAudioLevel(null);
412
+ return null;
413
+ }
414
+ render(<Probe />);
415
+
416
+ expect(result).toEqual({ level: 0, levels: [] });
417
+ expect(getUserMedia).not.toHaveBeenCalled();
418
+ });
419
+
420
+ it("degrades to empty levels (never throws) when a stream is given but no Web Audio API exists", () => {
421
+ const originalAudioContext = (window as { AudioContext?: unknown }).AudioContext;
422
+ // Deliberately deleting to simulate an environment with no Web Audio API.
423
+ // (No ESLint rule in this repo's config flags a dot-notation `delete` — the
424
+ // property is restored below regardless.)
425
+ delete (window as { AudioContext?: unknown }).AudioContext;
426
+
427
+ const fakeStream = {} as MediaStream;
428
+ let result: ReturnType<typeof useAudioLevel> | undefined;
429
+ function Probe() {
430
+ result = useAudioLevel(fakeStream);
431
+ return null;
432
+ }
433
+ expect(() => render(<Probe />)).not.toThrow();
434
+ expect(result).toEqual({ level: 0, levels: [] });
435
+
436
+ (window as { AudioContext?: unknown }).AudioContext = originalAudioContext;
437
+ });
438
+ });