@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
@@ -30,6 +30,7 @@ import {
30
30
  TableHead,
31
31
  TableHeader,
32
32
  TableRow,
33
+ useLocale,
33
34
  } from "@elabs-ai/components-ui";
34
35
  import { cn } from "@elabs-ai/components-ui/lib/cn";
35
36
  import type { HTMLAttributes, ReactNode } from "react";
@@ -72,18 +73,87 @@ interface ParsedCsv {
72
73
  rows: string[][];
73
74
  }
74
75
 
75
- /** Minimal preview-grade CSV split (no quoted-comma handling — it's a glance, not a grid). */
76
+ /**
77
+ * A small RFC 4180-ish CSV tokenizer: handles quoted fields (commas and
78
+ * newlines inside quotes don't end the field/row) and the `""` escaped-quote
79
+ * convention. Single pass over the raw string — no line pre-split, so a
80
+ * quoted field spanning multiple lines survives intact. Still preview-grade
81
+ * (unquoted cells are trimmed for the glance table), not a full CSV grammar.
82
+ */
83
+ function parseCsvRows(content: string): string[][] {
84
+ const rows: string[][] = [];
85
+ let row: string[] = [];
86
+ let field = "";
87
+ let wasQuoted = false;
88
+ let inQuotes = false;
89
+
90
+ const endField = () => {
91
+ row.push(wasQuoted ? field : field.trim());
92
+ field = "";
93
+ wasQuoted = false;
94
+ };
95
+ const endRow = () => {
96
+ endField();
97
+ rows.push(row);
98
+ row = [];
99
+ };
100
+
101
+ for (let i = 0; i < content.length; i++) {
102
+ const ch = content[i];
103
+
104
+ if (inQuotes) {
105
+ if (ch === '"') {
106
+ if (content[i + 1] === '"') {
107
+ field += '"';
108
+ i += 1;
109
+ } else {
110
+ inQuotes = false;
111
+ }
112
+ } else {
113
+ field += ch;
114
+ }
115
+ continue;
116
+ }
117
+
118
+ if (ch === '"' && field === "") {
119
+ inQuotes = true;
120
+ wasQuoted = true;
121
+ continue;
122
+ }
123
+
124
+ if (ch === ",") {
125
+ endField();
126
+ continue;
127
+ }
128
+
129
+ if (ch === "\r") {
130
+ continue;
131
+ }
132
+
133
+ if (ch === "\n") {
134
+ endRow();
135
+ continue;
136
+ }
137
+
138
+ field += ch;
139
+ }
140
+
141
+ // A trailing field/row with no terminating newline.
142
+ if (field !== "" || row.length > 0) {
143
+ endRow();
144
+ }
145
+
146
+ return rows;
147
+ }
148
+
76
149
  function parseCsv(content: string): ParsedCsv {
77
- const lines = content
78
- .trim()
79
- .split(/\r?\n/)
80
- .filter((line) => line.length > 0);
81
- const [head = "", ...rest] = lines;
82
- const split = (line: string) => line.split(",").map((cell) => cell.trim());
83
- return { header: split(head), rows: rest.map(split) };
150
+ const rows = parseCsvRows(content.trim()).filter((r) => !(r.length === 1 && r[0] === ""));
151
+ const [header = [], ...rest] = rows;
152
+ return { header, rows: rest };
84
153
  }
85
154
 
86
155
  const CsvPreview = ({ content }: { content: string }) => {
156
+ const { t } = useLocale();
87
157
  const { header, rows } = useMemo(() => parseCsv(content), [content]);
88
158
  return (
89
159
  <div className="flex flex-col gap-2">
@@ -109,7 +179,7 @@ const CsvPreview = ({ content }: { content: string }) => {
109
179
  </TableBody>
110
180
  </Table>
111
181
  <p className="text-meta text-muted-foreground">
112
- {rows.length} {rows.length === 1 ? "row" : "rows"}
182
+ {t("ai.assetPreview.rowCount", { count: rows.length })}
113
183
  </p>
114
184
  </div>
115
185
  );
@@ -135,6 +205,7 @@ export const AssetPreview = ({
135
205
  className,
136
206
  ...props
137
207
  }: AssetPreviewProps) => {
208
+ const { t } = useLocale();
138
209
  const [mode, setMode] = useState<AssetPreviewMode>(defaultMode);
139
210
  const injectedRenderer = useAssetPreviewRenderer();
140
211
  const content = asset.content ?? "";
@@ -150,7 +221,7 @@ export const AssetPreview = ({
150
221
  if (injected !== null) {
151
222
  body = injected;
152
223
  } else if (asset.type !== "image" && content.length === 0) {
153
- body = <p className="text-body text-muted-foreground">No preview available…</p>;
224
+ body = <p className="text-body text-muted-foreground">{t("ai.assetPreview.noPreview")}</p>;
154
225
  } else if (showRaw) {
155
226
  body = <CodeBlock code={content} language={assetLanguage(asset)} wrap />;
156
227
  } else {
@@ -197,7 +268,7 @@ export const AssetPreview = ({
197
268
  className={cn(!showRaw && "bg-accent text-accent-foreground")}
198
269
  onClick={() => setMode("preview")}
199
270
  >
200
- Preview
271
+ {t("ai.assetPreview.preview")}
201
272
  </Button>
202
273
  <Button
203
274
  type="button"
@@ -207,7 +278,7 @@ export const AssetPreview = ({
207
278
  className={cn(showRaw && "bg-accent text-accent-foreground")}
208
279
  onClick={() => setMode("raw")}
209
280
  >
210
- Raw
281
+ {t("ai.assetPreview.raw")}
211
282
  </Button>
212
283
  </ArtifactActions>
213
284
  ) : null}
@@ -192,8 +192,8 @@ export const Attachment = ({ data, onRemove, className, children, ...props }: At
192
192
  variant === "inline" && [
193
193
  "flex h-8 cursor-pointer select-none items-center gap-1.5",
194
194
  "rounded-md border border-border px-1.5",
195
- "font-medium text-sm transition-colors duration-fast ease-standard motion-reduce:transition-none",
196
- "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
195
+ "font-medium text-body transition-colors duration-fast ease-standard motion-reduce:transition-none",
196
+ "hover:bg-accent/50 hover:text-accent-foreground",
197
197
  ],
198
198
  variant === "list" && [
199
199
  "flex w-full items-center gap-3 rounded-lg border p-3",
@@ -283,7 +283,7 @@ export const AttachmentInfo = ({
283
283
  <div className={cn("min-w-0 flex-1", className)} {...props}>
284
284
  <span className="block truncate">{label}</span>
285
285
  {showMediaType && data.mediaType && (
286
- <span className="block truncate text-muted-foreground text-xs">{data.mediaType}</span>
286
+ <span className="block truncate text-muted-foreground text-meta">{data.mediaType}</span>
287
287
  )}
288
288
  </div>
289
289
  );
@@ -385,7 +385,10 @@ export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
385
385
 
386
386
  export const AttachmentEmpty = ({ className, children, ...props }: AttachmentEmptyProps) => (
387
387
  <div
388
- className={cn("flex items-center justify-center p-4 text-muted-foreground text-sm", className)}
388
+ className={cn(
389
+ "flex items-center justify-center p-4 text-muted-foreground text-body",
390
+ className,
391
+ )}
389
392
  {...props}
390
393
  >
391
394
  {children ?? "No attachments"}
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Issue #33 — `media-chrome` is an OPTIONAL peer of `@elabs-ai/components-ai`,
3
+ * reached only through `lazy(() => import("./_audio-player-media-chrome"))`
4
+ * (ADR 0019, `lazyPart` in `audio-player.tsx`). A REJECTED `lazy()` import
5
+ * throws during render — `Suspense` alone does not catch that (it only covers
6
+ * the pending state) — so every exported part is wrapped in
7
+ * `LazyEngineBoundary` (`_lazy-engine-boundary.tsx`). This locks that the
8
+ * boundary falls back gracefully for the top-level `AudioPlayer` AND for a
9
+ * sub-control, instead of taking the tree down.
10
+ *
11
+ * `vi.mock` factories are hoisted, so the mock itself must resolve
12
+ * successfully (a factory that throws or rejects gets intercepted by
13
+ * Vitest's OWN "error when mocking a module" diagnostic instead of the
14
+ * message this test needs — see `interactive-terminal-missing-peer.test.tsx`
15
+ * for the same finding). So the module resolves fine and the simulated
16
+ * failure instead comes from each named export THROWING when React renders
17
+ * it — a real render-phase error, which is exactly what `LazyEngineBoundary`
18
+ * exists to catch.
19
+ *
20
+ * The top-level `AudioPlayer` case asserts a SETTLED, non-loading notice
21
+ * (never a permanent `Skeleton`) — a loading placeholder left in place after
22
+ * the load has definitively failed reads as "still loading, forever", which
23
+ * `.claude/rules/loading-states.md` treats as a defect, not a graceful
24
+ * fallback. See `AudioPlayerMissing` in `audio-player.tsx`.
25
+ */
26
+ import { act, cleanup, render, screen } from "@testing-library/react";
27
+ import { afterEach, describe, expect, it, vi } from "vitest";
28
+
29
+ vi.mock("./_audio-player-media-chrome", () => ({
30
+ AudioPlayer: () => {
31
+ throw new Error("Cannot find module 'media-chrome'");
32
+ },
33
+ AudioPlayerPlayButton: () => {
34
+ throw new Error("Cannot find module 'media-chrome'");
35
+ },
36
+ }));
37
+
38
+ import { AudioPlayer, AudioPlayerPlayButton } from "./audio-player";
39
+
40
+ afterEach(cleanup);
41
+
42
+ const flush = () => act(async () => void (await new Promise((resolve) => setTimeout(resolve, 0))));
43
+
44
+ describe("AudioPlayer — missing optional peer (#33)", () => {
45
+ it("falls back to a settled, actionable notice — never a permanent skeleton — when media-chrome fails to load", async () => {
46
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
47
+
48
+ render(<AudioPlayer />);
49
+ await flush();
50
+
51
+ // The tree survived, and it settled: no permanent loading placeholder
52
+ // (a `Skeleton` left in place after a definitive failure would read as
53
+ // "still loading, forever" — the bug this locks against), and the
54
+ // notice names the exact missing package via `role="status"` (a missing
55
+ // optional peer is a capability gap, not a broken component).
56
+ const notice = screen.getByRole("status");
57
+ expect(notice).toHaveTextContent(/media-chrome/i);
58
+ expect(document.querySelector('[class*="animate-pulse"]')).toBeNull();
59
+ expect(errorSpy).toHaveBeenCalled();
60
+
61
+ errorSpy.mockRestore();
62
+ });
63
+
64
+ it("degrades a sub-control to nothing rather than crashing", async () => {
65
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
66
+
67
+ const { container } = render(<AudioPlayerPlayButton />);
68
+ await flush();
69
+
70
+ // `renderMissing={() => null}` for sub-controls — an empty, but intact,
71
+ // tree beats a crash.
72
+ expect(container).toBeEmptyDOMElement();
73
+ expect(errorSpy).toHaveBeenCalled();
74
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
75
+
76
+ errorSpy.mockRestore();
77
+ });
78
+ });
@@ -1,46 +1,191 @@
1
1
  "use client";
2
2
 
3
- import { Skeleton } from "@elabs-ai/components-ui";
3
+ import { Skeleton, isOptionalPeerMissing, useLocale } from "@elabs-ai/components-ui";
4
4
  import { cn } from "@elabs-ai/components-ui/lib/cn";
5
5
  import type { Experimental_SpeechResult as SpeechResult } from "ai";
6
- import type {
7
- MediaControlBar,
8
- MediaController,
9
- MediaDurationDisplay,
10
- MediaMuteButton,
11
- MediaPlayButton,
12
- MediaSeekBackwardButton,
13
- MediaSeekForwardButton,
14
- MediaTimeDisplay,
15
- MediaTimeRange,
16
- MediaVolumeRange,
17
- } from "media-chrome/react";
18
- import type { ComponentProps, ComponentType } from "react";
6
+ import { VolumeOffIcon } from "lucide-react";
7
+ import type { ComponentProps, ComponentType, HTMLAttributes } from "react";
19
8
  import { lazy, Suspense } from "react";
20
9
 
10
+ import { LazyEngineBoundary } from "./_lazy-engine-boundary";
11
+
21
12
  /**
22
13
  * media-chrome lives behind a dynamic import — it declares no `sideEffects`, so
23
14
  * a static import would put the whole custom-element library in every consumer's
24
15
  * entry chunk, `AudioPlayer` rendered or not. Every value import lives in
25
- * `_audio-player-media-chrome.tsx`; the element types above are TYPE imports and
26
- * erase. See ADR 0019 and `pnpm heavy-deps:check`.
16
+ * `_audio-player-media-chrome.tsx`. See ADR 0019 and `pnpm heavy-deps:check`.
27
17
  *
28
18
  * All ten parts resolve from the SAME module specifier, so they share one chunk:
29
19
  * once `AudioPlayer` has loaded it, the controls inside it resolve from cache.
20
+ *
21
+ * Issue #101: `media-chrome` is an OPTIONAL peer, so no PUBLIC export's type
22
+ * may structurally reference `media-chrome/react`'s own types (previously
23
+ * `ComponentProps<typeof MediaController>` and nine siblings, imported as
24
+ * TYPE-only above) — doing so names `media-chrome/react`'s module specifier in
25
+ * this package's generated root `.d.ts` and hands a `skipLibCheck: false`
26
+ * consumer who has correctly omitted the peer a `TS2307` just for importing
27
+ * the barrel — not just for using `AudioPlayer`. `AudioPlayerPartProps` below
28
+ * is an OWNED base type: `media-chrome/react`'s wrapper
29
+ * (`media-chrome/react`'s `createComponent`, from the `ce-la-react` helper it
30
+ * is built on) types every part as `Omit<HTMLAttributes<I>, …> &
31
+ * Partial<the element's own instance members>` — i.e. ordinary HTML attributes
32
+ * plus a handful of optional custom attributes. `AudioPlayerPartProps` itself
33
+ * stays ordinary HTML attributes only; the PRIMITIVE-typed (`string` /
34
+ * `boolean` / `number`) extra instance members of `<media-controller>` and
35
+ * `<media-seek-forward-button>` — the two elements measured by the round-2
36
+ * validator — are mirrored one-by-one on `AudioPlayerProps` and
37
+ * `AudioPlayerSeekForwardButtonProps` below (each a single declaration line
38
+ * referencing nothing peer-owned, the same technique `seekOffset` already
39
+ * used). What stays narrowed to `AudioPlayerPartProps` alone are the
40
+ * PEER/COMPLEX-typed members (`MediaStore`, `AttributeTokenList`,
41
+ * `MediaTooltip`, `TooltipPlacement`, DOM-element and `HTMLMediaElement`
42
+ * callbacks, methods) and every member of the other eight parts — restoring
43
+ * those would re-import the peer type graph this fix exists to avoid. See the
44
+ * CHANGELOG's "Breaking (types)" entry for the exact narrowed set.
45
+ * `AudioPlayerPartProps` is therefore NOT a full replica of media-chrome's
46
+ * surface, only the base every part actually uses and re-exports (the same
47
+ * scoping `TerminalColorTheme` uses for xterm's `ITheme` in
48
+ * `packages/terminal/src/interactive-terminal.tsx`, also issue #101). The ten
49
+ * conformance assertions at the bottom of `_audio-player-media-chrome.tsx`
50
+ * (which still imports the real peer types — that module is reached only
51
+ * through `lazy()` and never sits in the barrel's declaration graph) prove
52
+ * every owned type here stays assignable to its real media-chrome counterpart;
53
+ * a peer version bump that narrows a prop incompatibly fails
54
+ * `pnpm --filter @elabs-ai/components-ai typecheck` locally instead of
55
+ * reaching a consumer as silent drift.
56
+ *
57
+ * Deliberately no `ref` field: `ComponentProps<typeof MediaController>` (and
58
+ * its nine siblings) included one via `RefAttributes<I>`, but `RefObject<T>`'s
59
+ * mutable `current` makes ref types INVARIANT in `T` — a `Ref<HTMLElement>`
60
+ * can never be assignable to a real element's own `Ref<MediaController>` (the
61
+ * custom element subclass carries private/extra members `HTMLElement` lacks),
62
+ * so keeping it would make every owned type fail its own conformance
63
+ * assertion below. This IS a real-world break for a React 19 consumer: none
64
+ * of the ten parts this package renders is wrapped in `forwardRef`, but
65
+ * `MediaController` (and its siblings) are genuine `forwardRef` components,
66
+ * and React 19's ref-as-prop carries a `ref` straight through an ordinary
67
+ * function component's `{...props}` spread to the real underlying element —
68
+ * see the CHANGELOG's "Breaking (types)" entry for the measured detail. It is
69
+ * inert only under React 18.
30
70
  */
71
+ export type AudioPlayerPartProps = HTMLAttributes<HTMLElement>;
72
+
31
73
  type MediaChromeModule = typeof import("./_audio-player-media-chrome");
32
74
 
33
75
  const lazyPart = <P,>(pick: (module: MediaChromeModule) => ComponentType<P>) =>
34
76
  lazy(() => import("./_audio-player-media-chrome").then((m) => ({ default: pick(m) })));
35
77
 
36
- export type AudioPlayerProps = Omit<ComponentProps<typeof MediaController>, "audio">;
78
+ /**
79
+ * The primitive-typed (`string`/`boolean`/`number`) extra instance members of
80
+ * `media-chrome/react`'s `MediaController` — restored per issue #101's round-2
81
+ * validation (R2): each is a single declaration line mirroring the real peer
82
+ * type's name and shape, referencing nothing peer-owned, so it costs nothing
83
+ * against the `.d.ts` leak this fix exists to prevent. Deliberately EXCLUDED:
84
+ * `audio` (already narrowed out — `_audio-player-media-chrome.tsx`'s
85
+ * conformance assertion Omits it from the real `ComponentProps`, since this
86
+ * package renders its own `<AudioPlayerElement>` instead of passing one
87
+ * through), and every PEER/COMPLEX-typed member (`mediaStore`, `hotkeys`
88
+ * (`AttributeTokenList`), `media`/`fullscreenElement` (DOM elements),
89
+ * `mediaStateReceivers`, `associatedElementSubscriptions`, every
90
+ * `HTMLMediaElement` callback and every method) — those stay narrowed to
91
+ * `AudioPlayerPartProps` alone; restoring them would re-import the peer type
92
+ * graph. See `_audio-player-media-chrome.tsx`'s
93
+ * `_AudioPlayerPropsConformance` assertion, which fails typecheck if this
94
+ * interface ever drifts wider than the real element's own props.
95
+ *
96
+ * Doc comments below are inferred from each member's name and media-chrome's
97
+ * public attribute-naming convention (`no*` = boolean opt-out of the
98
+ * corresponding default-on behavior) — media-chrome ships no per-property doc
99
+ * comments in its own `.d.ts` or README to copy from. Treat them as a reading
100
+ * aid, not an authoritative spec; verify against media-chrome's source before
101
+ * relying on exact semantics.
102
+ */
103
+ export interface AudioPlayerProps extends AudioPlayerPartProps {
104
+ /** Seconds of inactivity before controls hide; a string because it mirrors the HTML attribute value. */
105
+ autohide?: string;
106
+ /** Also autohide while the pointer is over the control elements themselves, not just the media. */
107
+ autohideOverControls?: boolean;
108
+ /** Breakpoint definitions (space-separated `name:width` pairs) driving `breakpointsComputed`. */
109
+ breakpoints?: string;
110
+ /** Whether the controller has computed its current breakpoint(s) at least once. */
111
+ breakpointsComputed?: boolean;
112
+ /** Fallback duration (seconds) to display before the real media duration is known. */
113
+ defaultDuration?: number;
114
+ /** Default stream type ("on-demand" or "live") before the media has resolved its own. */
115
+ defaultStreamType?: string;
116
+ /** Whether subtitles/captions are enabled by default. */
117
+ defaultSubtitles?: boolean;
118
+ /** Disables the controller's built-in tap/gesture handling (e.g. tap-to-play, double-tap-to-seek). */
119
+ gesturesDisabled?: boolean;
120
+ /** Whether hotkey keyboard control is currently active on the controller. */
121
+ keyboardControl?: boolean;
122
+ /** Space-separated list of hotkey names currently in use, for conflict detection with other listeners. */
123
+ keysUsed?: string;
124
+ /** Seconds behind the live edge still considered "at" live, for a live stream's seek range. */
125
+ liveEdgeOffset?: number;
126
+ /** Disables the controller's autohide-controls-on-inactivity behavior entirely. */
127
+ noAutohide?: boolean;
128
+ /** Disables automatically seeking back to the live edge after a manual seek on a live stream. */
129
+ noAutoSeekToLive?: boolean;
130
+ /** Opts out of the controller creating its own default internal media store. */
131
+ noDefaultStore?: boolean;
132
+ /** Disables the controller's built-in keyboard hotkeys entirely. */
133
+ noHotkeys?: boolean;
134
+ /** Opts out of remembering the user's mute preference across sessions. */
135
+ noMutedPref?: boolean;
136
+ /** Opts out of remembering the user's subtitles-language preference across sessions. */
137
+ noSubtitlesLangPref?: boolean;
138
+ /** Opts out of remembering the user's volume preference across sessions. */
139
+ noVolumePref?: boolean;
140
+ /** The language the controller resolved for its UI text, after applying any language preference. */
141
+ resolvedLang?: string;
142
+ /** Whether the user has interacted with the controller yet (affects autoplay-adjacent UI). */
143
+ userInteractive?: boolean;
144
+ }
37
145
 
38
146
  const AudioPlayerImpl = lazyPart<AudioPlayerProps>((m) => m.AudioPlayer);
39
147
 
148
+ /**
149
+ * The settled-failure stand-in for the `LazyEngineBoundary` above — rendered
150
+ * once the `media-chrome` load has genuinely failed, never while it is still
151
+ * pending (that stays `Skeleton`, via the `Suspense fallback` below). A
152
+ * loading skeleton left in place after a settled failure reads as "still
153
+ * loading, forever" (loading-states.md) — this is a terminal state, so it
154
+ * gets a real (if compact) notice instead, sized to the same `h-10` slot so
155
+ * nothing shifts when the boundary trips.
156
+ */
157
+ const AudioPlayerMissing = ({ className, error }: { className?: string; error: unknown }) => {
158
+ const { t } = useLocale();
159
+ const isPeerMissing = isOptionalPeerMissing(error);
160
+ return (
161
+ <div
162
+ className={cn(
163
+ "flex h-10 w-full items-center gap-2 rounded-md border border-dashed border-border bg-muted/30 px-3 text-caption text-muted-foreground",
164
+ className,
165
+ )}
166
+ role={isPeerMissing ? "status" : "alert"}
167
+ >
168
+ <VolumeOffIcon aria-hidden="true" className="size-4 shrink-0" />
169
+ <span className="truncate">
170
+ {isPeerMissing
171
+ ? t("ai.error.engineMissingBody", {
172
+ feature: t("ai.audioPlayer.feature"),
173
+ packages: "media-chrome",
174
+ })
175
+ : t("ai.audioPlayer.renderError")}
176
+ </span>
177
+ </div>
178
+ );
179
+ };
180
+
40
181
  export const AudioPlayer = ({ className, ...props }: AudioPlayerProps) => (
41
- <Suspense fallback={<Skeleton className={cn("h-10 w-full rounded-md", className)} />}>
42
- <AudioPlayerImpl className={className} data-slot="audio-player" {...props} />
43
- </Suspense>
182
+ <LazyEngineBoundary
183
+ renderMissing={(error) => <AudioPlayerMissing className={className} error={error} />}
184
+ >
185
+ <Suspense fallback={<Skeleton className={cn("h-10 w-full rounded-md", className)} />}>
186
+ <AudioPlayerImpl className={className} data-slot="audio-player" {...props} />
187
+ </Suspense>
188
+ </LazyEngineBoundary>
44
189
  );
45
190
 
46
191
  export type AudioPlayerElementProps = Omit<ComponentProps<"audio">, "src"> &
@@ -63,108 +208,150 @@ export const AudioPlayerElement = ({ ...props }: AudioPlayerElementProps) => (
63
208
  />
64
209
  );
65
210
 
66
- export type AudioPlayerControlBarProps = ComponentProps<typeof MediaControlBar>;
211
+ export type AudioPlayerControlBarProps = AudioPlayerPartProps;
67
212
 
68
213
  const AudioPlayerControlBarImpl = lazyPart<AudioPlayerControlBarProps>(
69
214
  (m) => m.AudioPlayerControlBar,
70
215
  );
71
216
 
72
217
  export const AudioPlayerControlBar = (props: AudioPlayerControlBarProps) => (
73
- <Suspense fallback={null}>
74
- <AudioPlayerControlBarImpl data-slot="audio-player-control-bar" {...props} />
75
- </Suspense>
218
+ <LazyEngineBoundary renderMissing={() => null}>
219
+ <Suspense fallback={null}>
220
+ <AudioPlayerControlBarImpl data-slot="audio-player-control-bar" {...props} />
221
+ </Suspense>
222
+ </LazyEngineBoundary>
76
223
  );
77
224
 
78
- export type AudioPlayerPlayButtonProps = ComponentProps<typeof MediaPlayButton>;
225
+ export type AudioPlayerPlayButtonProps = AudioPlayerPartProps;
79
226
 
80
227
  const AudioPlayerPlayButtonImpl = lazyPart<AudioPlayerPlayButtonProps>(
81
228
  (m) => m.AudioPlayerPlayButton,
82
229
  );
83
230
 
84
231
  export const AudioPlayerPlayButton = (props: AudioPlayerPlayButtonProps) => (
85
- <Suspense fallback={null}>
86
- <AudioPlayerPlayButtonImpl data-slot="audio-player-play-button" {...props} />
87
- </Suspense>
232
+ <LazyEngineBoundary renderMissing={() => null}>
233
+ <Suspense fallback={null}>
234
+ <AudioPlayerPlayButtonImpl data-slot="audio-player-play-button" {...props} />
235
+ </Suspense>
236
+ </LazyEngineBoundary>
88
237
  );
89
238
 
90
- export type AudioPlayerSeekBackwardButtonProps = ComponentProps<typeof MediaSeekBackwardButton>;
239
+ export interface AudioPlayerSeekBackwardButtonProps extends AudioPlayerPartProps {
240
+ /** Skip distance in seconds. Mirrors the element's own default (10). */
241
+ seekOffset?: number;
242
+ }
91
243
 
92
244
  const AudioPlayerSeekBackwardButtonImpl = lazyPart<AudioPlayerSeekBackwardButtonProps>(
93
245
  (m) => m.AudioPlayerSeekBackwardButton,
94
246
  );
95
247
 
96
248
  export const AudioPlayerSeekBackwardButton = (props: AudioPlayerSeekBackwardButtonProps) => (
97
- <Suspense fallback={null}>
98
- <AudioPlayerSeekBackwardButtonImpl data-slot="audio-player-seek-backward-button" {...props} />
99
- </Suspense>
249
+ <LazyEngineBoundary renderMissing={() => null}>
250
+ <Suspense fallback={null}>
251
+ <AudioPlayerSeekBackwardButtonImpl data-slot="audio-player-seek-backward-button" {...props} />
252
+ </Suspense>
253
+ </LazyEngineBoundary>
100
254
  );
101
255
 
102
- export type AudioPlayerSeekForwardButtonProps = ComponentProps<typeof MediaSeekForwardButton>;
256
+ /**
257
+ * Primitive-typed extra members restored per issue #101's round-2 validation
258
+ * (R2) — same technique and same inferred-from-naming caveat as
259
+ * `AudioPlayerProps` above. Deliberately still narrowed out:
260
+ * `keysUsed` (here typed `string[]`, unlike the controller's own `string`
261
+ * member of the same name — an array is not primitive) and the DOM/method
262
+ * members `disable`/`enable`/`handleClick`/`tooltipEl`/`tooltipPlacement`.
263
+ */
264
+ export interface AudioPlayerSeekForwardButtonProps extends AudioPlayerPartProps {
265
+ /** Whether the button is disabled. */
266
+ disabled?: boolean;
267
+ /** ID of the `<media-controller>` element this button controls, when it is not an ancestor. */
268
+ mediaController?: string;
269
+ /** The media's current playback time (seconds), as reflected by the controller's media store. */
270
+ mediaCurrentTime?: number;
271
+ /** Skip distance in seconds. Mirrors the element's own default (10). */
272
+ seekOffset?: number;
273
+ /** Suppresses the built-in hover tooltip on this button. */
274
+ noTooltip?: boolean;
275
+ /** Prevents the button's default click handling (for a consumer that wants to fully own the behavior). */
276
+ preventClick?: boolean;
277
+ }
103
278
 
104
279
  const AudioPlayerSeekForwardButtonImpl = lazyPart<AudioPlayerSeekForwardButtonProps>(
105
280
  (m) => m.AudioPlayerSeekForwardButton,
106
281
  );
107
282
 
108
283
  export const AudioPlayerSeekForwardButton = (props: AudioPlayerSeekForwardButtonProps) => (
109
- <Suspense fallback={null}>
110
- <AudioPlayerSeekForwardButtonImpl data-slot="audio-player-seek-forward-button" {...props} />
111
- </Suspense>
284
+ <LazyEngineBoundary renderMissing={() => null}>
285
+ <Suspense fallback={null}>
286
+ <AudioPlayerSeekForwardButtonImpl data-slot="audio-player-seek-forward-button" {...props} />
287
+ </Suspense>
288
+ </LazyEngineBoundary>
112
289
  );
113
290
 
114
- export type AudioPlayerTimeDisplayProps = ComponentProps<typeof MediaTimeDisplay>;
291
+ export type AudioPlayerTimeDisplayProps = AudioPlayerPartProps;
115
292
 
116
293
  const AudioPlayerTimeDisplayImpl = lazyPart<AudioPlayerTimeDisplayProps>(
117
294
  (m) => m.AudioPlayerTimeDisplay,
118
295
  );
119
296
 
120
297
  export const AudioPlayerTimeDisplay = (props: AudioPlayerTimeDisplayProps) => (
121
- <Suspense fallback={null}>
122
- <AudioPlayerTimeDisplayImpl data-slot="audio-player-time-display" {...props} />
123
- </Suspense>
298
+ <LazyEngineBoundary renderMissing={() => null}>
299
+ <Suspense fallback={null}>
300
+ <AudioPlayerTimeDisplayImpl data-slot="audio-player-time-display" {...props} />
301
+ </Suspense>
302
+ </LazyEngineBoundary>
124
303
  );
125
304
 
126
- export type AudioPlayerTimeRangeProps = ComponentProps<typeof MediaTimeRange>;
305
+ export type AudioPlayerTimeRangeProps = AudioPlayerPartProps;
127
306
 
128
307
  const AudioPlayerTimeRangeImpl = lazyPart<AudioPlayerTimeRangeProps>((m) => m.AudioPlayerTimeRange);
129
308
 
130
309
  export const AudioPlayerTimeRange = (props: AudioPlayerTimeRangeProps) => (
131
- <Suspense fallback={null}>
132
- <AudioPlayerTimeRangeImpl data-slot="audio-player-time-range" {...props} />
133
- </Suspense>
310
+ <LazyEngineBoundary renderMissing={() => null}>
311
+ <Suspense fallback={null}>
312
+ <AudioPlayerTimeRangeImpl data-slot="audio-player-time-range" {...props} />
313
+ </Suspense>
314
+ </LazyEngineBoundary>
134
315
  );
135
316
 
136
- export type AudioPlayerDurationDisplayProps = ComponentProps<typeof MediaDurationDisplay>;
317
+ export type AudioPlayerDurationDisplayProps = AudioPlayerPartProps;
137
318
 
138
319
  const AudioPlayerDurationDisplayImpl = lazyPart<AudioPlayerDurationDisplayProps>(
139
320
  (m) => m.AudioPlayerDurationDisplay,
140
321
  );
141
322
 
142
323
  export const AudioPlayerDurationDisplay = (props: AudioPlayerDurationDisplayProps) => (
143
- <Suspense fallback={null}>
144
- <AudioPlayerDurationDisplayImpl data-slot="audio-player-duration-display" {...props} />
145
- </Suspense>
324
+ <LazyEngineBoundary renderMissing={() => null}>
325
+ <Suspense fallback={null}>
326
+ <AudioPlayerDurationDisplayImpl data-slot="audio-player-duration-display" {...props} />
327
+ </Suspense>
328
+ </LazyEngineBoundary>
146
329
  );
147
330
 
148
- export type AudioPlayerMuteButtonProps = ComponentProps<typeof MediaMuteButton>;
331
+ export type AudioPlayerMuteButtonProps = AudioPlayerPartProps;
149
332
 
150
333
  const AudioPlayerMuteButtonImpl = lazyPart<AudioPlayerMuteButtonProps>(
151
334
  (m) => m.AudioPlayerMuteButton,
152
335
  );
153
336
 
154
337
  export const AudioPlayerMuteButton = (props: AudioPlayerMuteButtonProps) => (
155
- <Suspense fallback={null}>
156
- <AudioPlayerMuteButtonImpl data-slot="audio-player-mute-button" {...props} />
157
- </Suspense>
338
+ <LazyEngineBoundary renderMissing={() => null}>
339
+ <Suspense fallback={null}>
340
+ <AudioPlayerMuteButtonImpl data-slot="audio-player-mute-button" {...props} />
341
+ </Suspense>
342
+ </LazyEngineBoundary>
158
343
  );
159
344
 
160
- export type AudioPlayerVolumeRangeProps = ComponentProps<typeof MediaVolumeRange>;
345
+ export type AudioPlayerVolumeRangeProps = AudioPlayerPartProps;
161
346
 
162
347
  const AudioPlayerVolumeRangeImpl = lazyPart<AudioPlayerVolumeRangeProps>(
163
348
  (m) => m.AudioPlayerVolumeRange,
164
349
  );
165
350
 
166
351
  export const AudioPlayerVolumeRange = (props: AudioPlayerVolumeRangeProps) => (
167
- <Suspense fallback={null}>
168
- <AudioPlayerVolumeRangeImpl data-slot="audio-player-volume-range" {...props} />
169
- </Suspense>
352
+ <LazyEngineBoundary renderMissing={() => null}>
353
+ <Suspense fallback={null}>
354
+ <AudioPlayerVolumeRangeImpl data-slot="audio-player-volume-range" {...props} />
355
+ </Suspense>
356
+ </LazyEngineBoundary>
170
357
  );