@elabs-ai/components-ai 5.4.0 → 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.
- package/README.md +4 -6
- package/dist/_lazy-engine-boundary.js +1 -1
- package/dist/_persona-rive.js.map +1 -1
- package/dist/_streamdown-i18n.js +2 -2
- package/dist/agent.js +2 -2
- package/dist/asset-preview.js +5 -5
- package/dist/attachments.js +1 -1
- package/dist/audio-player.js +1 -2
- package/dist/{chunk-7RL6KZDF.js → chunk-24M6XWVJ.js} +25 -17
- package/dist/chunk-24M6XWVJ.js.map +1 -0
- package/dist/{chunk-VJZJWNEC.js → chunk-3KXIWGHW.js} +3 -3
- package/dist/{chunk-TWPHAJNM.js → chunk-3UHR3RLZ.js} +12 -6
- package/dist/chunk-3UHR3RLZ.js.map +1 -0
- package/dist/{chunk-V4DORGWJ.js → chunk-5AMSNG2T.js} +2 -2
- package/dist/chunk-D7BZMRI3.js +2 -0
- package/dist/chunk-D7BZMRI3.js.map +1 -0
- package/dist/{chunk-PQBF6ZIQ.js → chunk-DI645WR4.js} +4 -4
- package/dist/{chunk-NCL2UZKI.js → chunk-FFA7OPEH.js} +2 -2
- package/dist/{chunk-34CQ2VHM.js → chunk-FO44KY7Y.js} +10 -7
- package/dist/chunk-FO44KY7Y.js.map +1 -0
- package/dist/{chunk-RB7G65JC.js → chunk-GP7ZZCID.js} +7 -5
- package/dist/chunk-GP7ZZCID.js.map +1 -0
- package/dist/{chunk-6PB2MXCI.js → chunk-HLODND65.js} +2 -2
- package/dist/{chunk-ADOYAGGK.js → chunk-IFVO7LQJ.js} +2 -2
- package/dist/{chunk-2XOIG5TG.js → chunk-JSEV46SH.js} +18 -12
- package/dist/chunk-JSEV46SH.js.map +1 -0
- package/dist/{chunk-CZP3RHC4.js → chunk-LY7O4BMP.js} +2 -2
- package/dist/{chunk-AOOIAFDT.js → chunk-OCZNA5SO.js} +2 -2
- package/dist/{chunk-S3ISPW3A.js → chunk-PWQPVOZJ.js} +1 -1
- package/dist/chunk-PWQPVOZJ.js.map +1 -0
- package/dist/chunk-RLV64EWK.js +41 -0
- package/dist/chunk-RLV64EWK.js.map +1 -0
- package/dist/chunk-SSES3F4Q.js +126 -0
- package/dist/chunk-SSES3F4Q.js.map +1 -0
- package/dist/{chunk-3XNPMDZY.js → chunk-TBCMCF5O.js} +9 -56
- package/dist/chunk-TBCMCF5O.js.map +1 -0
- package/dist/{chunk-UHLVPAHG.js → chunk-WXUXFDJQ.js} +2 -2
- package/dist/{chunk-X47QMM6K.js → chunk-YLN7E4OV.js} +2 -2
- package/dist/code-block.js +1 -1
- package/dist/diff-view.js +2 -2
- package/dist/gallery.js +1 -1
- package/dist/generated-image.js +9 -0
- package/dist/generated-image.js.map +1 -0
- package/dist/grouped-parts.js +6 -6
- package/dist/image.js +4 -4
- package/dist/index.d.ts +92 -150
- package/dist/index.js +67 -65
- package/dist/markdown-view.js +3 -3
- package/dist/message.js +3 -3
- package/dist/model-provider-logo.js +1 -1
- package/dist/persona.js +2 -2
- package/dist/queue.js +1 -1
- package/dist/reasoning.js +3 -3
- package/dist/sandbox.js +3 -3
- package/dist/tool.js +2 -2
- package/package.json +8 -13
- package/schemas/a2ui-surface.v1.schema.json +1 -1
- package/src/__contract__/{image.contract.test.tsx → attachments.contract.test.tsx} +7 -7
- package/src/__contract__/generated-image.contract.test.tsx +49 -0
- package/src/_lazy-boundary-conformance.ts +12 -18
- package/src/_lazy-engine-boundary.tsx +1 -2
- package/src/_lazy-mermaid-absent.test.ts +2 -2
- package/src/_media-fixtures.ts +47 -0
- package/src/_persona-rive.tsx +1 -2
- package/src/a2ui/catalog.source.json +1 -1
- package/src/asset-preview.stories.tsx +12 -0
- package/src/asset-preview.test.tsx +14 -0
- package/src/asset-preview.tsx +4 -2
- package/src/attachments.stories.tsx +147 -0
- package/src/attachments.test.tsx +104 -0
- package/src/attachments.tsx +28 -20
- package/src/audio-player.stories.tsx +10 -45
- package/src/audio-player.test.tsx +166 -61
- package/src/audio-player.tsx +175 -266
- package/src/chat.stories.tsx +75 -3
- package/src/code-block.test.tsx +32 -0
- package/src/code-block.tsx +22 -5
- package/src/gallery.tsx +13 -77
- package/src/generated-image.stories.tsx +67 -0
- package/src/generated-image.test.tsx +107 -0
- package/src/generated-image.tsx +57 -0
- package/src/image.tsx +2 -115
- package/src/index.ts +1 -0
- package/src/model-provider-logo.tsx +20 -18
- package/src/queue.test.tsx +24 -0
- package/src/queue.tsx +8 -5
- package/dist/_audio-player-media-chrome.js +0 -85
- package/dist/_audio-player-media-chrome.js.map +0 -1
- package/dist/chunk-2XOIG5TG.js.map +0 -1
- package/dist/chunk-34CQ2VHM.js.map +0 -1
- package/dist/chunk-3XNPMDZY.js.map +0 -1
- package/dist/chunk-7RL6KZDF.js.map +0 -1
- package/dist/chunk-NSCLRHES.js +0 -102
- package/dist/chunk-NSCLRHES.js.map +0 -1
- package/dist/chunk-RB7G65JC.js.map +0 -1
- package/dist/chunk-S3ISPW3A.js.map +0 -1
- package/dist/chunk-TWPHAJNM.js.map +0 -1
- package/dist/chunk-V6E5DYZD.js +0 -80
- package/dist/chunk-V6E5DYZD.js.map +0 -1
- package/src/_audio-player-media-chrome.tsx +0 -233
- package/src/image.stories.tsx +0 -67
- package/src/image.test.tsx +0 -105
- /package/dist/{chunk-VJZJWNEC.js.map → chunk-3KXIWGHW.js.map} +0 -0
- /package/dist/{chunk-V4DORGWJ.js.map → chunk-5AMSNG2T.js.map} +0 -0
- /package/dist/{chunk-PQBF6ZIQ.js.map → chunk-DI645WR4.js.map} +0 -0
- /package/dist/{chunk-NCL2UZKI.js.map → chunk-FFA7OPEH.js.map} +0 -0
- /package/dist/{chunk-6PB2MXCI.js.map → chunk-HLODND65.js.map} +0 -0
- /package/dist/{chunk-ADOYAGGK.js.map → chunk-IFVO7LQJ.js.map} +0 -0
- /package/dist/{chunk-CZP3RHC4.js.map → chunk-LY7O4BMP.js.map} +0 -0
- /package/dist/{chunk-AOOIAFDT.js.map → chunk-OCZNA5SO.js.map} +0 -0
- /package/dist/{chunk-UHLVPAHG.js.map → chunk-WXUXFDJQ.js.map} +0 -0
- /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
|
+
}
|
package/src/_persona-rive.tsx
CHANGED
|
@@ -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
|
|
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), 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) }"
|
|
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",
|
|
@@ -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();
|
package/src/asset-preview.tsx
CHANGED
|
@@ -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
|
-
<
|
|
246
|
+
<Image
|
|
246
247
|
src={content || asset.path}
|
|
247
248
|
alt={asset.name}
|
|
249
|
+
fit="contain"
|
|
248
250
|
loading="lazy"
|
|
249
|
-
className="
|
|
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
|
+
});
|
package/src/attachments.tsx
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
};
|