@elabs-ai/components-ai 5.3.1 → 5.5.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 (115) hide show
  1. package/README.md +4 -6
  2. package/dist/_lazy-engine-boundary.js +1 -1
  3. package/dist/_persona-rive.js.map +1 -1
  4. package/dist/_streamdown-i18n.js +2 -2
  5. package/dist/agent.js +2 -2
  6. package/dist/asset-preview.js +5 -5
  7. package/dist/attachments.js +1 -1
  8. package/dist/audio-player.js +1 -2
  9. package/dist/{chunk-7RL6KZDF.js → chunk-24M6XWVJ.js} +25 -17
  10. package/dist/chunk-24M6XWVJ.js.map +1 -0
  11. package/dist/{chunk-WU5BLGTF.js → chunk-3KXIWGHW.js} +3 -3
  12. package/dist/{chunk-TWPHAJNM.js → chunk-3UHR3RLZ.js} +12 -6
  13. package/dist/chunk-3UHR3RLZ.js.map +1 -0
  14. package/dist/{chunk-V4DORGWJ.js → chunk-5AMSNG2T.js} +2 -2
  15. package/dist/chunk-D7BZMRI3.js +2 -0
  16. package/dist/chunk-D7BZMRI3.js.map +1 -0
  17. package/dist/{chunk-PQBF6ZIQ.js → chunk-DI645WR4.js} +4 -4
  18. package/dist/{chunk-NCL2UZKI.js → chunk-FFA7OPEH.js} +2 -2
  19. package/dist/{chunk-34CQ2VHM.js → chunk-FO44KY7Y.js} +10 -7
  20. package/dist/chunk-FO44KY7Y.js.map +1 -0
  21. package/dist/{chunk-RB7G65JC.js → chunk-GP7ZZCID.js} +7 -5
  22. package/dist/chunk-GP7ZZCID.js.map +1 -0
  23. package/dist/{chunk-6PB2MXCI.js → chunk-HLODND65.js} +2 -2
  24. package/dist/{chunk-ADOYAGGK.js → chunk-IFVO7LQJ.js} +2 -2
  25. package/dist/{chunk-2XOIG5TG.js → chunk-JSEV46SH.js} +18 -12
  26. package/dist/chunk-JSEV46SH.js.map +1 -0
  27. package/dist/{chunk-CZP3RHC4.js → chunk-LY7O4BMP.js} +2 -2
  28. package/dist/{chunk-PTWCTSZL.js → chunk-OCZNA5SO.js} +4 -4
  29. package/dist/chunk-OCZNA5SO.js.map +1 -0
  30. package/dist/{chunk-S3ISPW3A.js → chunk-PWQPVOZJ.js} +1 -1
  31. package/dist/chunk-PWQPVOZJ.js.map +1 -0
  32. package/dist/chunk-RLV64EWK.js +41 -0
  33. package/dist/chunk-RLV64EWK.js.map +1 -0
  34. package/dist/chunk-SSES3F4Q.js +126 -0
  35. package/dist/chunk-SSES3F4Q.js.map +1 -0
  36. package/dist/{chunk-3XNPMDZY.js → chunk-TBCMCF5O.js} +9 -56
  37. package/dist/chunk-TBCMCF5O.js.map +1 -0
  38. package/dist/{chunk-FERYDFZA.js → chunk-WXUXFDJQ.js} +2 -2
  39. package/dist/{chunk-X47QMM6K.js → chunk-YLN7E4OV.js} +2 -2
  40. package/dist/code-block.js +1 -1
  41. package/dist/diff-view.js +2 -2
  42. package/dist/gallery.js +1 -1
  43. package/dist/generated-image.js +9 -0
  44. package/dist/generated-image.js.map +1 -0
  45. package/dist/grouped-parts.js +6 -6
  46. package/dist/image.js +4 -4
  47. package/dist/index.d.ts +92 -150
  48. package/dist/index.js +67 -65
  49. package/dist/markdown-view.js +3 -3
  50. package/dist/message.js +3 -3
  51. package/dist/model-provider-logo.js +1 -1
  52. package/dist/persona.js +2 -2
  53. package/dist/queue.js +1 -1
  54. package/dist/reasoning.js +3 -3
  55. package/dist/sandbox.js +3 -3
  56. package/dist/tool.js +2 -2
  57. package/package.json +8 -13
  58. package/schemas/a2ui-surface.v1.schema.json +5 -2
  59. package/src/__contract__/{image.contract.test.tsx → attachments.contract.test.tsx} +7 -7
  60. package/src/__contract__/generated-image.contract.test.tsx +49 -0
  61. package/src/_lazy-boundary-conformance.ts +12 -18
  62. package/src/_lazy-engine-boundary.tsx +1 -2
  63. package/src/_lazy-mermaid-absent.test.ts +2 -2
  64. package/src/_media-fixtures.ts +47 -0
  65. package/src/_persona-rive.tsx +1 -2
  66. package/src/a2ui/catalog.source.json +3 -2
  67. package/src/asset-preview.stories.tsx +12 -0
  68. package/src/asset-preview.test.tsx +14 -0
  69. package/src/asset-preview.tsx +4 -2
  70. package/src/attachments.stories.tsx +147 -0
  71. package/src/attachments.test.tsx +104 -0
  72. package/src/attachments.tsx +28 -20
  73. package/src/audio-player.stories.tsx +10 -45
  74. package/src/audio-player.test.tsx +166 -61
  75. package/src/audio-player.tsx +175 -266
  76. package/src/chat.stories.tsx +75 -3
  77. package/src/code-block.test.tsx +32 -0
  78. package/src/code-block.tsx +22 -5
  79. package/src/gallery.tsx +13 -77
  80. package/src/generated-image.stories.tsx +67 -0
  81. package/src/generated-image.test.tsx +107 -0
  82. package/src/generated-image.tsx +57 -0
  83. package/src/image.tsx +2 -115
  84. package/src/index.ts +1 -0
  85. package/src/model-provider-logo.tsx +20 -18
  86. package/src/queue.test.tsx +24 -0
  87. package/src/queue.tsx +8 -5
  88. package/src/tool.test.tsx +26 -0
  89. package/src/tool.tsx +2 -2
  90. package/dist/_audio-player-media-chrome.js +0 -85
  91. package/dist/_audio-player-media-chrome.js.map +0 -1
  92. package/dist/chunk-2XOIG5TG.js.map +0 -1
  93. package/dist/chunk-34CQ2VHM.js.map +0 -1
  94. package/dist/chunk-3XNPMDZY.js.map +0 -1
  95. package/dist/chunk-7RL6KZDF.js.map +0 -1
  96. package/dist/chunk-NSCLRHES.js +0 -102
  97. package/dist/chunk-NSCLRHES.js.map +0 -1
  98. package/dist/chunk-PTWCTSZL.js.map +0 -1
  99. package/dist/chunk-RB7G65JC.js.map +0 -1
  100. package/dist/chunk-S3ISPW3A.js.map +0 -1
  101. package/dist/chunk-TWPHAJNM.js.map +0 -1
  102. package/dist/chunk-V6E5DYZD.js +0 -80
  103. package/dist/chunk-V6E5DYZD.js.map +0 -1
  104. package/src/_audio-player-media-chrome.tsx +0 -233
  105. package/src/image.stories.tsx +0 -67
  106. package/src/image.test.tsx +0 -105
  107. /package/dist/{chunk-WU5BLGTF.js.map → chunk-3KXIWGHW.js.map} +0 -0
  108. /package/dist/{chunk-V4DORGWJ.js.map → chunk-5AMSNG2T.js.map} +0 -0
  109. /package/dist/{chunk-PQBF6ZIQ.js.map → chunk-DI645WR4.js.map} +0 -0
  110. /package/dist/{chunk-NCL2UZKI.js.map → chunk-FFA7OPEH.js.map} +0 -0
  111. /package/dist/{chunk-6PB2MXCI.js.map → chunk-HLODND65.js.map} +0 -0
  112. /package/dist/{chunk-ADOYAGGK.js.map → chunk-IFVO7LQJ.js.map} +0 -0
  113. /package/dist/{chunk-CZP3RHC4.js.map → chunk-LY7O4BMP.js.map} +0 -0
  114. /package/dist/{chunk-FERYDFZA.js.map → chunk-WXUXFDJQ.js.map} +0 -0
  115. /package/dist/{chunk-X47QMM6K.js.map → chunk-YLN7E4OV.js.map} +0 -0
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Story fixtures synthesized at module scope, so no story needs a network
3
+ * origin (`docs/CSP-AND-NETWORK.md`) or a binary file. Internal to this
4
+ * package's stories — not exported from the barrel.
5
+ */
6
+
7
+ /** A silent 8-bit mono PCM WAV of `seconds` length, as a data URL. */
8
+ export function silentWavDataUrl(seconds = 1.5, sampleRate = 8000): string {
9
+ const samples = Math.floor(seconds * sampleRate);
10
+ const bytes = new Uint8Array(44 + samples).fill(128); // 128 == silence (8-bit unsigned)
11
+ const view = new DataView(bytes.buffer);
12
+ const ascii = (offset: number, text: string) => {
13
+ for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
14
+ };
15
+ ascii(0, "RIFF");
16
+ view.setUint32(4, 36 + samples, true);
17
+ ascii(8, "WAVEfmt ");
18
+ view.setUint32(16, 16, true); // PCM header size
19
+ view.setUint16(20, 1, true); // format: PCM
20
+ view.setUint16(22, 1, true); // channels: mono
21
+ view.setUint32(24, sampleRate, true);
22
+ view.setUint32(28, sampleRate, true); // byte rate
23
+ view.setUint16(32, 1, true); // block align
24
+ view.setUint16(34, 8, true); // bits per sample
25
+ ascii(36, "data");
26
+ view.setUint32(40, samples, true);
27
+ let binary = "";
28
+ for (const byte of bytes) binary += String.fromCharCode(byte);
29
+ return `data:audio/wav;base64,${btoa(binary)}`;
30
+ }
31
+
32
+ /**
33
+ * A screenshot-like chart panel (axes, a latency line with a spike, a caption),
34
+ * as an SVG data URL. Paints with CSS system colours only, so no raw colour
35
+ * literal enters this package.
36
+ */
37
+ export function placeholderChartDataUrl(caption: string): string {
38
+ const svg =
39
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 400">` +
40
+ `<rect width="640" height="400" fill="Canvas"/>` +
41
+ `<rect x="0.5" y="0.5" width="639" height="399" fill="none" stroke="GrayText" opacity="0.5"/>` +
42
+ `<path d="M60 40 V320 H600" fill="none" stroke="GrayText" stroke-width="2"/>` +
43
+ `<path d="M60 250 L160 240 L260 255 L360 235 L420 120 L480 140 L540 210 L600 215" fill="none" stroke="Highlight" stroke-width="4" stroke-linejoin="round"/>` +
44
+ `<text x="330" y="370" font-family="sans-serif" font-size="22" text-anchor="middle" fill="CanvasText">${caption}</text>` +
45
+ `</svg>`;
46
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
47
+ }
@@ -277,8 +277,7 @@ export default PersonaRive;
277
277
  * typecheck — caught by `pnpm --filter @elabs-ai/components-ai typecheck`
278
278
  * locally, never shipped as a silent mismatch to a consumer.
279
279
  *
280
- * `AssertAssignable` itself is shared with `_audio-player-media-chrome.tsx`
281
- * via `_lazy-boundary-conformance.ts` — see that module's doc comment for
280
+ * `AssertAssignable` itself lives in `_lazy-boundary-conformance.ts` — see that module's doc comment for
282
281
  * what this check can and cannot prove (it is one-directional: it cannot
283
282
  * catch the owned type being NARROWER than the real one, which is exactly
284
283
  * what `PersonaRiveEvent["data"]` is — see the CHANGELOG's "Breaking (types)"
@@ -574,7 +574,7 @@
574
574
  "spec": {
575
575
  "type": "object",
576
576
  "required": true,
577
- "description": "{ data: row[], x: string, series: string[] | { key, label?, axis?: left|right, mark?: line|area|column }[] (type: \"dual-axis\" only: default axis left, mark line; needs ≥1 line series, no right-axis columns), type?: line|area|bar|pie|scatter|radar|funnel|candlestick|heatmap|calendar|waterfall|dumbbell|unit|treemap|histogram|box|strip|bump|stream|diverging-bar|dual-axis, xType?: time|category|number, y2?, axes?: { y2?: { align?: independent|ticks, proportional?: boolean, zero?: both|auto } } (type: \"dual-axis\" only, right axis vs left; default align ticks, zero auto), group?, title?, description?, stacked?: boolean|percent|diverging, orientation?: vertical|horizontal, donut?, legend?: boolean (an object form exists; AutoChart reads it as boolean truthy today), valueFormat?: number|compact|currency|percent, currency?, palette?: mono|sequential|categorical, emphasis?: analytical|editorial, kind?: steps|records|ranking|change (change: a two-measure spec reads as a before/after move, for dumbbell), nulls?: gap|zero|connect (line/area/stream non-numeric sample, default gap), curve?: linear|monotone|natural|step|step-before|step-after (line/area/stream, default monotone), symbols?: { placement?: all|ends|first|last, shape?: circle|square|triangle|diamond|cross|star|plus|hexagon, style?: filled|hollow, size? } (line/area/stream point markers), size?: { key, range?: [lo,hi] } (scatter bubble size), shapeBy?: { key, shapes?: marker-shape[] } (scatter shape by category), trend?: linear|log (scatter trend line), shapes?: [{ kind: line|path, … }] (scatter reference lines/areas), variant?: dumbbell|slope|arrow|dots (dumbbell only, default dumbbell), delta?: { show, mode: absolute|percent } (dumbbell delta label), groupSmall?: { threshold?, max?, label? } (pie: fold small slices into an Other slice), half?: boolean (pie half-donut, default false), labels?: { series?: end|key|none, values?: { placement: first|last|all|peaks, count?, minGap?, outline?, matchColor?, format? }, points?: { key, mode?: auto|all, priorityKey? }, slices?: { placement?: inside|outside|none, show: (label|value|percent)[], matchColor?, minAngle? }, comparison?: value|difference|none } (label engine), annotations?: [{ kind: text|range|line|row, … }] (notes, bands, reference lines, row notes in data units), divergingCenter?: string (neutral series when stacked is diverging), sort?: asc|desc|none|{by,dir} (bar) | start|end|delta|deltaPercent|data|label|none (dumbbell) | desc|none (pie) | data|increasesFirst|decreasesFirst (waterfall, default data), groupBy?: string (bar/dumbbell row grouping; waterfall: a subtotal after each group), colorBy?: { key, scale?: categorical|sequential|diverging, steps? } (bar per-bar / scatter per-point colour), overlays?: [{ kind: value|range, … }] (bar value markers, range spans), comparison?: { key, label? } (bar muted prior-period column), notes?: string (italic notes under an enclosing ChartFrame), byline?: { kind?: chart|map|table, author } (ChartFrame footer: kind + author), source?: string | { name, href? } (ChartFrame footer attribution), altText?: string (image text alternative, default: description), tooltip?: { variant?: rows|table|inline, focus?: boolean, pin?: boolean } (forwarded to ChartTooltip, default rows), facet?: { by: string | { series: true }, columns?, scales?: { y?: shared|independent, rangeRounding? }, sort?: start|end|delta|deltaPercent|range|title|data, baseline?: { key } | { series }, panelHeight? } (line/area/bar/pie small multiples), dataFormat?: differences|runningTotals (waterfall only, default differences), zoomToDifferences?: boolean (waterfall only, default false) }"
577
+ "description": "{ data: row[], x: string, series: string[] | { key, label?, axis?: left|right, mark?: line|area|column }[] (type: \"dual-axis\" only: default axis left, mark line; needs ≥1 line series, no right-axis columns), type?: line|area|bar|pie|scatter|radar|funnel|candlestick|heatmap|calendar|waterfall|dumbbell|unit|treemap|histogram|box|strip|bump|stream|diverging-bar|dual-axis, xType?: time|category|number, y2?, axes?: { y2?: { align?: independent|ticks, proportional?: boolean, zero?: both|auto } } (type: \"dual-axis\" only, right axis vs left; default align ticks, zero auto), group?, title?, description?, stacked?: boolean|percent|diverging, orientation?: vertical|horizontal, donut?, legend?: boolean (an object form exists; AutoChart reads it as boolean truthy today), valueFormat?: number|compact|currency|percent, currency?, palette?: mono|sequential|categorical, emphasis?: analytical|editorial, kind?: steps|records|ranking|change (change: a two-measure spec reads as a before/after move, for dumbbell), nulls?: gap|zero|connect (line/area/stream non-numeric sample, default gap), curve?: linear|monotone|natural|step|step-before|step-after (line/area/stream, default monotone), symbols?: { placement?: all|ends|first|last, shape?: circle|square|triangle|diamond|cross|star|plus|hexagon, style?: filled|hollow, size? } (line/area/stream point markers), size?: { key, range?: [lo,hi] } (scatter bubble size), shapeBy?: { key, shapes?: marker-shape[] } (scatter shape by category), trend?: linear|log (scatter trend line), shapes?: [{ kind: line|path, … }] (scatter reference lines/areas), variant?: dumbbell|slope|arrow|dots (dumbbell only, default dumbbell), valueKeys?: string[] (dumbbell variant dots only: one dot per key, in order, and one legend entry each; default the two measures), delta?: { show, mode: absolute|percent } (dumbbell delta label), groupSmall?: { threshold?, max?, label? } (pie: fold small slices into an Other slice), half?: boolean (pie half-donut, default false), labels?: { series?: end|key|none, values?: { placement: first|last|all|peaks, count?, minGap?, outline?, matchColor?, format? }, points?: { key, mode?: auto|all, priorityKey? }, slices?: { placement?: inside|outside|none, show: (label|value|percent)[], matchColor?, minAngle? }, comparison?: value|difference|none } (label engine), annotations?: [{ kind: text|range|line|row, … }] (notes, bands, reference lines, row notes in data units), analytics?: [{ kind: line|band|trend|window|forecast|errorBars, of?: series|all, … }] (computed overlays — line: { value: mean|median|min|max|sum|number|{ percentile }|{ stddev, around? }, axis?: x|y, label?: none|value|computation|text, ifOverflow?: clip|extend }; band: { from, to } | { spread: { percentiles: [lo,hi] }|{ stddev }|{ ci } }; trend: { model?: linear|log|exp|pow|{ poly: 2..6 }|{ loess }, ci?, extent?: data|domain }; window: { k, reduce?: mean|median|sum|min|max|ewm, replace? }; forecast: { horizon, season?, interval? }; errorBars: { low: field|{ percent }, high?, band? }), selection?: { gestures: (range|rect|lasso|radial)[], confirm?: immediate|explicit, field? } (bar/line/area/scatter/heatmap/histogram/box/strip selection gestures with a toolbar — a bar chart with range and lasso selection is { gestures: [range, lasso] }; explicit previews until the reader confirms; intents arrive as the selectionIntent event), divergingCenter?: string (neutral series when stacked is diverging), sort?: asc|desc|none|{by,dir} (bar) | start|end|delta|deltaPercent|data|label|none (dumbbell) | desc|none (pie) | data|increasesFirst|decreasesFirst (waterfall, default data), groupBy?: string (bar/dumbbell row grouping; waterfall: a subtotal after each group), colorBy?: { key, scale?: categorical|sequential|diverging, steps? } (bar per-bar / scatter per-point colour), overlays?: [{ kind: value|range, … }] (bar value markers, range spans), comparison?: { key, label? } (bar muted prior-period column), notes?: string (italic notes under an enclosing ChartFrame), byline?: { kind?: chart|map|table, author } (ChartFrame footer: kind + author), source?: string | { name, href? } (ChartFrame footer attribution), altText?: string (image text alternative, default: description), tooltip?: { variant?: rows|table|inline, focus?: boolean, pin?: boolean } (forwarded to ChartTooltip, default rows), facet?: { by: string | { series: true }, columns?, scales?: { y?: shared|independent, rangeRounding? }, sort?: start|end|delta|deltaPercent|range|title|data, baseline?: { key } | { series }, panelHeight? } (line/area/bar/pie small multiples), dataFormat?: differences|runningTotals (waterfall only, default differences), zoomToDifferences?: boolean (waterfall only, default false), scrollbar?: miniChart|bar|auto|none (bar/diverging-bar/heatmap/calendar and line/area on a category x: an overview strip that scrolls the categories; default none, or auto when maxVisibleItems is set), maxVisibleItems?: number (categories shown at once, the rest scroll behind the strip and the value axis keeps the full domain; for > 30 categories set maxVisibleItems) }"
578
578
  },
579
579
  "height": {
580
580
  "type": "number",
@@ -585,7 +585,8 @@
585
585
  }
586
586
  },
587
587
  "events": {
588
- "datapointClick": "onDatapointClick"
588
+ "datapointClick": "onDatapointClick",
589
+ "selectionIntent": "onSelectionIntent"
589
590
  },
590
591
  "omit": ["copyValueOnActivate", "dimExcluded"]
591
592
  },
@@ -59,6 +59,18 @@ export const Csv: Story = {
59
59
  },
60
60
  };
61
61
 
62
+ /** Offline, deterministic placeholder (inline SVG data URL) — no network round-trip. */
63
+ const CHART_IMAGE = `data:image/svg+xml,${encodeURIComponent(
64
+ '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200"><rect width="320" height="200" fill="#46618a"/><rect x="40" y="120" width="40" height="60" fill="#b08949"/><rect x="110" y="80" width="40" height="100" fill="#b08949"/><rect x="180" y="50" width="40" height="130" fill="#b08949"/></svg>',
65
+ )}`;
66
+
67
+ /** Image renders through the ui `Image` primitive (`fit="contain"`, lazy) — no Raw toggle. */
68
+ export const Image: Story = {
69
+ args: {
70
+ asset: { id: "revenue-chart", name: "revenue-chart.svg", type: "image", content: CHART_IMAGE },
71
+ },
72
+ };
73
+
62
74
  /** Raw mode as the initial state. */
63
75
  export const RawByDefault: Story = {
64
76
  args: {
@@ -46,6 +46,20 @@ describe("AssetPreview (#193, research 04 §5 ASSET-4)", () => {
46
46
  expect(screen.getByText("2 rows")).toBeInTheDocument();
47
47
  });
48
48
 
49
+ it("renders an image through the ui Image primitive, with no Raw toggle", () => {
50
+ render(
51
+ <AssetPreview
52
+ asset={{ id: "chart", name: "chart.png", type: "image", content: "/chart.png" }}
53
+ />,
54
+ );
55
+ const img = screen.getByRole("img", { name: "chart.png" });
56
+ expect(img).toHaveAttribute("src", "/chart.png");
57
+ expect(img).toHaveAttribute("data-slot", "image");
58
+ expect(img).toHaveAttribute("loading", "lazy");
59
+ expect(img).toHaveClass("object-contain");
60
+ expect(screen.queryByRole("button", { name: "Raw" })).not.toBeInTheDocument();
61
+ });
62
+
49
63
  it("renders an empty state for missing content, never broken UI", () => {
50
64
  render(<AssetPreview asset={{ id: "x", name: "empty.md", type: "markdown" }} />);
51
65
  expect(screen.getByText("No preview available…")).toBeInTheDocument();
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import {
26
26
  Button,
27
+ Image,
27
28
  Table,
28
29
  TableBody,
29
30
  TableCell,
@@ -242,11 +243,12 @@ export const AssetPreview = ({
242
243
  }
243
244
  case "image": {
244
245
  body = (
245
- <img
246
+ <Image
246
247
  src={content || asset.path}
247
248
  alt={asset.name}
249
+ fit="contain"
248
250
  loading="lazy"
249
- className="max-w-full rounded-md"
251
+ className="rounded-md"
250
252
  />
251
253
  );
252
254
  break;
@@ -0,0 +1,147 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, within } from "storybook/test";
3
+ import {
4
+ Attachment,
5
+ AttachmentHoverCard,
6
+ AttachmentHoverCardContent,
7
+ AttachmentHoverCardTrigger,
8
+ AttachmentInfo,
9
+ AttachmentPreview,
10
+ AttachmentRemove,
11
+ Attachments,
12
+ type AttachmentData,
13
+ } from "./attachments";
14
+ import { silentWavDataUrl } from "./_media-fixtures";
15
+
16
+ /** Offline, deterministic placeholder (inline SVG data URL) — no network round-trip. */
17
+ function photo(label: string, bg: string): string {
18
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192"><rect width="192" height="192" fill="${bg}"/><text x="96" y="104" font-family="system-ui,sans-serif" font-size="20" fill="#ffffff" text-anchor="middle">${label}</text></svg>`;
19
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
20
+ }
21
+
22
+ const ITEMS: AttachmentData[] = [
23
+ {
24
+ id: "photo",
25
+ type: "file",
26
+ mediaType: "image/svg+xml",
27
+ filename: "harbour-fog.svg",
28
+ url: photo("Harbour", "#46618a"),
29
+ },
30
+ {
31
+ id: "clip",
32
+ type: "file",
33
+ mediaType: "video/webm",
34
+ filename: "product-demo.webm",
35
+ url: silentWavDataUrl(1, 3000),
36
+ },
37
+ {
38
+ id: "report",
39
+ type: "file",
40
+ mediaType: "application/pdf",
41
+ filename: "q3-board-report.pdf",
42
+ url: "",
43
+ },
44
+ {
45
+ id: "source",
46
+ type: "source-document",
47
+ sourceId: "src-1",
48
+ mediaType: "text/html",
49
+ title: "Revenue recognition policy",
50
+ filename: "policy.html",
51
+ },
52
+ ];
53
+
54
+ const noop = () => {};
55
+
56
+ const meta = {
57
+ title: "AI/Attachments",
58
+ component: Attachments,
59
+ tags: ["autodocs"],
60
+ parameters: {
61
+ layout: "padded",
62
+ docs: {
63
+ description: {
64
+ component:
65
+ "Files attached to a message or a composer — image and video thumbnails through the ui `Image`/`Video` primitives, a typed icon for everything else — as a grid of tiles, an inline row of chips or a full-width list, each with an optional remove action.",
66
+ },
67
+ },
68
+ },
69
+ } satisfies Meta<typeof Attachments>;
70
+ export default meta;
71
+ type Story = StoryObj<typeof meta>;
72
+
73
+ /** Grid (the default) — 96 px tiles: image and video thumbnails, icons for the rest. */
74
+ export const Default: Story = {
75
+ args: {
76
+ variant: "grid",
77
+ children: ITEMS.map((item) => (
78
+ <Attachment key={item.id} data={item} onRemove={noop}>
79
+ <AttachmentPreview />
80
+ <AttachmentRemove />
81
+ </Attachment>
82
+ )),
83
+ },
84
+ play: async ({ canvasElement }) => {
85
+ const canvas = within(canvasElement);
86
+ await expect(canvas.getByRole("img", { name: "harbour-fog.svg" })).toBeInTheDocument();
87
+ // The video thumbnail is decorative: hidden from AT, muted, no controls.
88
+ const video = canvasElement.querySelector("video");
89
+ await expect(video).not.toBeNull();
90
+ await expect(video?.muted).toBe(true);
91
+ await expect(video?.controls).toBe(false);
92
+ await expect(video?.closest('[aria-hidden="true"]')).not.toBeNull();
93
+ },
94
+ };
95
+
96
+ /** Inline — compact chips for a composer row. */
97
+ export const Inline: Story = {
98
+ args: {
99
+ variant: "inline",
100
+ children: ITEMS.map((item) => (
101
+ <Attachment key={item.id} data={item} onRemove={noop}>
102
+ <AttachmentPreview />
103
+ <AttachmentInfo />
104
+ <AttachmentRemove />
105
+ </Attachment>
106
+ )),
107
+ },
108
+ };
109
+
110
+ /** List — full-width rows with the media type. */
111
+ export const List: Story = {
112
+ args: {
113
+ variant: "list",
114
+ className: "max-w-sm",
115
+ children: ITEMS.map((item) => (
116
+ <Attachment key={item.id} data={item} onRemove={noop}>
117
+ <AttachmentPreview />
118
+ <AttachmentInfo showMediaType />
119
+ <AttachmentRemove />
120
+ </Attachment>
121
+ )),
122
+ },
123
+ };
124
+
125
+ /** Preview — an inline chip that opens a larger preview on hover. */
126
+ export const Preview: Story = {
127
+ args: {
128
+ variant: "inline",
129
+ children: ITEMS.slice(0, 2).map((item) => (
130
+ <AttachmentHoverCard key={item.id}>
131
+ <AttachmentHoverCardTrigger asChild>
132
+ <Attachment data={item}>
133
+ <AttachmentPreview />
134
+ <AttachmentInfo />
135
+ </Attachment>
136
+ </AttachmentHoverCardTrigger>
137
+ <AttachmentHoverCardContent>
138
+ <Attachments variant="grid">
139
+ <Attachment data={item}>
140
+ <AttachmentPreview />
141
+ </Attachment>
142
+ </Attachments>
143
+ </AttachmentHoverCardContent>
144
+ </AttachmentHoverCard>
145
+ )),
146
+ },
147
+ };
@@ -0,0 +1,104 @@
1
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ Attachment,
5
+ AttachmentInfo,
6
+ AttachmentPreview,
7
+ AttachmentRemove,
8
+ Attachments,
9
+ getMediaCategory,
10
+ type AttachmentData,
11
+ type AttachmentVariant,
12
+ } from "./attachments";
13
+
14
+ afterEach(cleanup);
15
+
16
+ const IMAGE: AttachmentData = {
17
+ id: "photo",
18
+ type: "file",
19
+ mediaType: "image/png",
20
+ filename: "photo.png",
21
+ url: "data:image/png;base64,iVBORw0KGgo=",
22
+ };
23
+ const VIDEO: AttachmentData = {
24
+ id: "clip",
25
+ type: "file",
26
+ mediaType: "video/mp4",
27
+ filename: "clip.mp4",
28
+ url: "data:video/mp4;base64,AAAA",
29
+ };
30
+ const DOC: AttachmentData = {
31
+ id: "doc",
32
+ type: "file",
33
+ mediaType: "application/pdf",
34
+ filename: "report.pdf",
35
+ url: "",
36
+ };
37
+
38
+ function renderAll(variant: AttachmentVariant, onRemove = vi.fn()) {
39
+ render(
40
+ <Attachments variant={variant}>
41
+ {[IMAGE, VIDEO, DOC].map((item) => (
42
+ <Attachment key={item.id} data={item} onRemove={() => onRemove(item.id)}>
43
+ <AttachmentPreview />
44
+ <AttachmentInfo />
45
+ <AttachmentRemove />
46
+ </Attachment>
47
+ ))}
48
+ </Attachments>,
49
+ );
50
+ return onRemove;
51
+ }
52
+
53
+ describe("Attachments", () => {
54
+ it.each<AttachmentVariant>(["grid", "inline", "list"])(
55
+ "renders image and video thumbnails in the %s variant",
56
+ (variant) => {
57
+ renderAll(variant);
58
+ const img = screen.getByRole("img", { name: "photo.png" });
59
+ expect(img.tagName).toBe("IMG");
60
+ expect(img).toHaveAttribute("data-slot", "image");
61
+ expect(img).toHaveClass("object-cover");
62
+ expect(img).toHaveAttribute("width", variant === "grid" ? "96" : "20");
63
+
64
+ const video = document.querySelector("video");
65
+ expect(video).not.toBeNull();
66
+ expect(video?.muted).toBe(true);
67
+ expect(video).not.toHaveAttribute("controls");
68
+ expect(video?.closest('[aria-hidden="true"]')).not.toBeNull();
69
+ },
70
+ );
71
+
72
+ it("shows the file name outside the grid variant, never in it", () => {
73
+ renderAll("list");
74
+ expect(screen.getByText("report.pdf")).toBeInTheDocument();
75
+ cleanup();
76
+ renderAll("grid");
77
+ expect(screen.queryByText("report.pdf")).not.toBeInTheDocument();
78
+ });
79
+
80
+ it("calls onRemove for the clicked item only", () => {
81
+ const onRemove = renderAll("list");
82
+ fireEvent.click(screen.getAllByRole("button", { name: "Remove" })[0]!);
83
+ expect(onRemove).toHaveBeenCalledTimes(1);
84
+ expect(onRemove).toHaveBeenCalledWith("photo");
85
+ });
86
+
87
+ it("renders no remove button without an onRemove callback", () => {
88
+ render(
89
+ <Attachments>
90
+ <Attachment data={IMAGE}>
91
+ <AttachmentPreview />
92
+ <AttachmentRemove />
93
+ </Attachment>
94
+ </Attachments>,
95
+ );
96
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
97
+ });
98
+
99
+ it("classifies media types", () => {
100
+ expect(getMediaCategory(IMAGE)).toBe("image");
101
+ expect(getMediaCategory(VIDEO)).toBe("video");
102
+ expect(getMediaCategory(DOC)).toBe("document");
103
+ });
104
+ });
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button } from "@elabs-ai/components-ui";
3
+ import { Button, Image, Video } from "@elabs-ai/components-ui";
4
4
  import { HoverCard, HoverCardContent, HoverCardTrigger } from "@elabs-ai/components-ui";
5
5
  import { cn } from "@elabs-ai/components-ui/lib/cn";
6
6
  import type { FileUIPart, SourceDocumentUIPart } from "ai";
@@ -79,24 +79,17 @@ export const getAttachmentLabel = (data: AttachmentData): string => {
79
79
  return data.filename || (category === "image" ? "Image" : "Attachment");
80
80
  };
81
81
 
82
- const renderAttachmentImage = (url: string, filename: string | undefined, isGrid: boolean) =>
83
- isGrid ? (
84
- <img
85
- alt={filename || "Image"}
86
- className="size-full object-cover"
87
- height={96}
88
- src={url}
89
- width={96}
90
- />
91
- ) : (
92
- <img
93
- alt={filename || "Image"}
94
- className="size-full rounded object-cover"
95
- height={20}
96
- src={url}
97
- width={20}
98
- />
99
- );
82
+ const renderAttachmentImage = (url: string, filename: string | undefined, isGrid: boolean) => (
83
+ <Image
84
+ alt={filename || "Image"}
85
+ className={isGrid ? "size-full" : "size-full rounded"}
86
+ fit="cover"
87
+ height={isGrid ? 96 : 20}
88
+ showSkeleton={false}
89
+ src={url}
90
+ width={isGrid ? 96 : 20}
91
+ />
92
+ );
100
93
 
101
94
  // ============================================================================
102
95
  // Contexts
@@ -157,6 +150,8 @@ export const Attachments = ({
157
150
  variant === "grid" && "ms-auto w-fit",
158
151
  className,
159
152
  )}
153
+ data-slot="attachments"
154
+ data-variant={variant}
160
155
  {...props}
161
156
  >
162
157
  {children}
@@ -201,6 +196,7 @@ export const Attachment = ({ data, onRemove, className, children, ...props }: At
201
196
  ],
202
197
  className,
203
198
  )}
199
+ data-slot="attachment"
204
200
  {...props}
205
201
  >
206
202
  {children}
@@ -236,7 +232,19 @@ export const AttachmentPreview = ({
236
232
  }
237
233
 
238
234
  if (mediaCategory === "video" && data.type === "file" && data.url) {
239
- return <video className="size-full object-cover" muted src={data.url} />;
235
+ // Control-less, decorative thumbnail: the attachment's name is the label.
236
+ return (
237
+ <Video
238
+ src={data.url}
239
+ muted
240
+ preload="metadata"
241
+ controls={false}
242
+ fit="cover"
243
+ aria-hidden
244
+ tabIndex={-1}
245
+ className="size-full"
246
+ />
247
+ );
240
248
  }
241
249
 
242
250
  const Icon = mediaCategoryIcons[mediaCategory];
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { expect, waitFor } from "storybook/test";
3
3
 
4
+ import { silentWavDataUrl } from "./_media-fixtures";
4
5
  import {
5
6
  AudioPlayer,
6
7
  AudioPlayerControlBar,
@@ -24,11 +25,10 @@ const meta = {
24
25
  docs: {
25
26
  description: {
26
27
  component:
27
- "A token-themed **media-chrome** audio player for spoken agent replies. Every " +
28
- "control is a media-chrome custom element re-skinned through the `--media-*` " +
29
- "custom properties, so it tracks the active theme. The engine is loaded through " +
30
- "a dynamic `import()` (ADR 0019) it is not in any consumer's entry chunk — so " +
31
- "the player renders a layout-shaped `Skeleton` for the first frame.",
28
+ "An audio player for spoken agent replies thin presets over ui's " +
29
+ "`MediaPlayer*` parts, so the controls are real buttons and sliders themed " +
30
+ "through tokens. Keyboard: Space/k play·pause, ←/→ seek, ↑/↓ volume, m mute. " +
31
+ "The root is transparent; the bubble or card around it is the surface.",
32
32
  },
33
33
  },
34
34
  },
@@ -37,36 +37,7 @@ const meta = {
37
37
  export default meta;
38
38
  type Story = StoryObj<typeof meta>;
39
39
 
40
- /**
41
- * A ~1.5 s silent 8-bit mono WAV, synthesized at module scope so the story needs
42
- * no network origin (see `docs/CSP-AND-NETWORK.md`) and no binary fixture.
43
- */
44
- function silentWavSrc(seconds = 1.5, sampleRate = 8000): string {
45
- const samples = Math.floor(seconds * sampleRate);
46
- const bytes = new Uint8Array(44 + samples).fill(128); // 128 == silence, 8-bit unsigned
47
- const view = new DataView(bytes.buffer);
48
- const ascii = (offset: number, text: string) => {
49
- for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
50
- };
51
- ascii(0, "RIFF");
52
- view.setUint32(4, 36 + samples, true);
53
- ascii(8, "WAVEfmt ");
54
- view.setUint32(16, 16, true); // PCM header size
55
- view.setUint16(20, 1, true); // format: PCM
56
- view.setUint16(22, 1, true); // channels: mono
57
- view.setUint32(24, sampleRate, true);
58
- view.setUint32(28, sampleRate, true); // byte rate
59
- view.setUint16(32, 1, true); // block align
60
- view.setUint16(34, 8, true); // bits per sample
61
- ascii(36, "data");
62
- view.setUint32(40, samples, true);
63
-
64
- let binary = "";
65
- for (const byte of bytes) binary += String.fromCharCode(byte);
66
- return `data:audio/wav;base64,${btoa(binary)}`;
67
- }
68
-
69
- const SILENT_WAV = silentWavSrc();
40
+ const SILENT_WAV = silentWavDataUrl();
70
41
 
71
42
  export const Default: Story = {
72
43
  render: () => (
@@ -85,15 +56,9 @@ export const Default: Story = {
85
56
  </AudioPlayer>
86
57
  ),
87
58
  play: async ({ canvasElement }) => {
88
- // media-chrome arrives in a lazy chunk (#313): the controls are absent for
89
- // the first frame, so this also locks in that the chunk actually resolves.
90
- await waitFor(
91
- () => {
92
- expect(canvasElement.querySelector("media-controller")).not.toBeNull();
93
- expect(canvasElement.querySelector("media-play-button")).not.toBeNull();
94
- expect(canvasElement.querySelector("media-time-range")).not.toBeNull();
95
- },
96
- { timeout: 10000 },
97
- );
59
+ await waitFor(() => {
60
+ expect(canvasElement.querySelector('[data-slot="media-player-play-button"]')).not.toBeNull();
61
+ expect(canvasElement.querySelector('[data-slot="media-player-time-slider"]')).not.toBeNull();
62
+ });
98
63
  },
99
64
  };