@runtypelabs/persona 4.6.1 → 4.8.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 (120) hide show
  1. package/dist/animations/glyph-cycle.cjs +2 -2
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/glyph-cycle.js +2 -2
  5. package/dist/animations/{types-CSmiKRVa.d.cts → types-BsZtXPKK.d.cts} +43 -3
  6. package/dist/animations/{types-CSmiKRVa.d.ts → types-BsZtXPKK.d.ts} +43 -3
  7. package/dist/animations/wipe.cjs +2 -2
  8. package/dist/animations/wipe.d.cts +1 -1
  9. package/dist/animations/wipe.d.ts +1 -1
  10. package/dist/chunk-5EIIHQLQ.js +1 -0
  11. package/dist/codegen.cjs +12 -12
  12. package/dist/codegen.js +14 -14
  13. package/dist/index.cjs +91 -68
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +918 -174
  16. package/dist/index.d.ts +918 -174
  17. package/dist/index.global.js +81 -57
  18. package/dist/index.global.js.map +1 -1
  19. package/dist/index.js +89 -66
  20. package/dist/index.js.map +1 -1
  21. package/dist/install.global.js +1 -1
  22. package/dist/install.global.js.map +1 -1
  23. package/dist/launcher.global.js +3 -2
  24. package/dist/launcher.global.js.map +1 -1
  25. package/dist/markdown-parsers.js +24 -24
  26. package/dist/plugin-kit.cjs +1 -1
  27. package/dist/plugin-kit.js +1 -1
  28. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  29. package/dist/runtype-tts.js +1 -1
  30. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  31. package/dist/smart-dom-reader.cjs +17 -17
  32. package/dist/smart-dom-reader.d.cts +753 -15
  33. package/dist/smart-dom-reader.d.ts +753 -15
  34. package/dist/smart-dom-reader.js +17 -17
  35. package/dist/testing.cjs +3 -3
  36. package/dist/testing.js +3 -3
  37. package/dist/theme-editor-preview.cjs +81 -58
  38. package/dist/theme-editor-preview.d.cts +761 -15
  39. package/dist/theme-editor-preview.d.ts +761 -15
  40. package/dist/theme-editor-preview.js +81 -58
  41. package/dist/theme-editor.cjs +6 -6
  42. package/dist/theme-editor.d.cts +753 -15
  43. package/dist/theme-editor.d.ts +753 -15
  44. package/dist/theme-editor.js +10 -10
  45. package/dist/theme-reference.cjs +1 -1
  46. package/dist/theme-reference.d.cts +74 -0
  47. package/dist/theme-reference.d.ts +74 -0
  48. package/dist/theme-reference.js +1 -1
  49. package/dist/voice-worklet-player.cjs +2 -2
  50. package/dist/voice-worklet-player.js +2 -2
  51. package/dist/webmcp-polyfill.js +2 -2
  52. package/dist/widget.css +1 -1
  53. package/package.json +2 -3
  54. package/src/artifacts-session.test.ts +178 -0
  55. package/src/client.test.ts +500 -1
  56. package/src/client.ts +285 -93
  57. package/src/components/artifact-card.test.ts +333 -0
  58. package/src/components/artifact-card.ts +75 -28
  59. package/src/components/artifact-inline.test.ts +1328 -0
  60. package/src/components/artifact-inline.ts +920 -0
  61. package/src/components/artifact-pane.test.ts +1042 -0
  62. package/src/components/artifact-pane.ts +440 -131
  63. package/src/components/artifact-preview.test.ts +1155 -0
  64. package/src/components/artifact-preview.ts +994 -0
  65. package/src/components/pill-composer-builder.test.ts +6 -2
  66. package/src/components/pill-composer-builder.ts +6 -6
  67. package/src/components/reasoning-bubble.ts +1 -13
  68. package/src/components/registry.ts +38 -3
  69. package/src/components/tool-bubble.ts +1 -13
  70. package/src/defaults.ts +1 -0
  71. package/src/generated/runtype-openapi-contract.ts +55 -3
  72. package/src/index-core.ts +20 -1
  73. package/src/index.ts +8 -0
  74. package/src/markdown-parsers-loader.test.ts +158 -0
  75. package/src/markdown-parsers-loader.ts +74 -9
  76. package/src/runtime/host-layout.test.ts +163 -0
  77. package/src/runtime/host-layout.ts +110 -7
  78. package/src/runtime/init.ts +18 -61
  79. package/src/runtime/persist-state.test.ts +118 -0
  80. package/src/session.ts +76 -22
  81. package/src/styles/widget.css +773 -26
  82. package/src/theme-editor/preview.ts +2 -0
  83. package/src/theme-editor/sections.test.ts +26 -1
  84. package/src/theme-editor/sections.ts +10 -2
  85. package/src/theme-reference.ts +2 -2
  86. package/src/tool-call-display-defaults.test.ts +1 -0
  87. package/src/types/theme.ts +77 -0
  88. package/src/types.ts +516 -17
  89. package/src/ui.artifact-pane-gating.test.ts +636 -0
  90. package/src/ui.component-directive.test.ts +104 -0
  91. package/src/ui.composer-bar.test.ts +60 -2
  92. package/src/ui.detached-panel.test.ts +1049 -0
  93. package/src/ui.scroll-additive.test.ts +1 -1
  94. package/src/ui.streaming-coalescing.test.ts +312 -0
  95. package/src/ui.tool-display.test.ts +51 -0
  96. package/src/ui.ts +933 -214
  97. package/src/utils/artifact-custom-actions.ts +128 -0
  98. package/src/utils/artifact-display.test.ts +42 -0
  99. package/src/utils/artifact-display.ts +84 -0
  100. package/src/utils/artifact-file.test.ts +116 -0
  101. package/src/utils/artifact-file.ts +117 -0
  102. package/src/utils/artifact-gate.test.ts +112 -5
  103. package/src/utils/artifact-gate.ts +39 -14
  104. package/src/utils/artifact-loading-status.ts +55 -0
  105. package/src/utils/artifact-status-label.ts +190 -0
  106. package/src/utils/buttons.ts +7 -1
  107. package/src/utils/code-highlight.test.ts +186 -0
  108. package/src/utils/code-highlight.ts +400 -0
  109. package/src/utils/icons.ts +2 -0
  110. package/src/utils/roving-tablist.test.ts +152 -0
  111. package/src/utils/roving-tablist.ts +111 -0
  112. package/src/utils/spinner.ts +45 -0
  113. package/src/utils/theme.test.ts +48 -0
  114. package/src/utils/theme.ts +7 -0
  115. package/src/utils/tokens.ts +91 -0
  116. package/src/utils/tool-loading-animation.test.ts +32 -0
  117. package/src/utils/tool-loading-animation.ts +24 -0
  118. package/dist/chunk-DFBSCFYN.js +0 -1
  119. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  120. package/dist/session-reconnect-U77QFUR7.js +0 -1
@@ -0,0 +1,333 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { PersonaArtifactCard } from "./artifact-card";
5
+ import { componentRegistry, type ComponentRenderer } from "./registry";
6
+ import type { ComponentContext } from "./registry";
7
+ import type {
8
+ AgentWidgetArtifactsFeature,
9
+ AgentWidgetConfig,
10
+ PersonaArtifactStatusLabelContext,
11
+ } from "../types";
12
+
13
+ const makeContext = (
14
+ artifacts?: AgentWidgetArtifactsFeature
15
+ ): ComponentContext =>
16
+ ({
17
+ message: {},
18
+ config: {
19
+ features: { artifacts: { enabled: true, ...(artifacts ?? {}) } },
20
+ } as AgentWidgetConfig,
21
+ updateProps: () => {},
22
+ }) as unknown as ComponentContext;
23
+
24
+ const render = (
25
+ props: Record<string, unknown>,
26
+ artifacts?: AgentWidgetArtifactsFeature
27
+ ): HTMLElement => PersonaArtifactCard(props, makeContext(artifacts));
28
+
29
+ const streamingProps = {
30
+ artifactId: "a1",
31
+ title: "index.html",
32
+ status: "streaming",
33
+ artifactType: "markdown",
34
+ file: { path: "index.html", mimeType: "text/html", language: "html" },
35
+ };
36
+
37
+ const NBSP = String.fromCharCode(0xa0);
38
+
39
+ const findStatusText = (root: HTMLElement): HTMLElement | null =>
40
+ root.querySelector(
41
+ ".persona-tool-loading-shimmer, .persona-tool-loading-shimmer-color, .persona-tool-loading-rainbow, .persona-tool-loading-pulse"
42
+ ) as HTMLElement | null;
43
+
44
+ describe("PersonaArtifactCard streaming status", () => {
45
+ it("renders shimmer animation by default with no pulsing dot", () => {
46
+ const root = render(streamingProps);
47
+
48
+ // No leftover pulsing dot element.
49
+ expect(root.querySelector(".persona-rounded-full")).toBeNull();
50
+ expect(root.innerHTML).not.toContain("persona-pulse ");
51
+
52
+ const statusText = findStatusText(root);
53
+ expect(statusText).not.toBeNull();
54
+ expect(statusText!.classList.contains("persona-tool-loading-shimmer")).toBe(
55
+ true
56
+ );
57
+ expect(statusText!.getAttribute("data-preserve-animation")).toBe("true");
58
+ expect(
59
+ statusText!.style.getPropertyValue("--persona-tool-anim-duration")
60
+ ).toBe("2000ms");
61
+
62
+ const chars = statusText!.querySelectorAll(".persona-tool-char");
63
+ expect(chars.length).toBeGreaterThan(0);
64
+ expect((chars[0] as HTMLElement).style.getPropertyValue("--char-index")).toBe(
65
+ "0"
66
+ );
67
+ // Reconstruct the animated text from the character spans.
68
+ const text = Array.from(chars)
69
+ .map((c) => (c.textContent === NBSP ? " " : c.textContent))
70
+ .join("");
71
+ expect(text).toBe("Generating html...");
72
+ });
73
+
74
+ it("renders plain text with no animation classes when loadingAnimation is none", () => {
75
+ const root = render(streamingProps, { loadingAnimation: "none" });
76
+
77
+ expect(findStatusText(root)).toBeNull();
78
+ expect(root.querySelector(".persona-tool-char")).toBeNull();
79
+ expect(root.textContent).toContain("Generating html...");
80
+ });
81
+
82
+ it("applies the pulse class to the status element without char spans", () => {
83
+ const root = render(streamingProps, { loadingAnimation: "pulse" });
84
+
85
+ const statusText = root.querySelector(
86
+ ".persona-tool-loading-pulse"
87
+ ) as HTMLElement | null;
88
+ expect(statusText).not.toBeNull();
89
+ expect(statusText!.getAttribute("data-preserve-animation")).toBe("true");
90
+ expect(statusText!.querySelector(".persona-tool-char")).toBeNull();
91
+ expect(statusText!.textContent).toBe("Generating html...");
92
+ });
93
+
94
+ it("sets color CSS vars for shimmer-color mode", () => {
95
+ const root = render(streamingProps, {
96
+ loadingAnimation: "shimmer-color",
97
+ loadingAnimationColor: "#111111",
98
+ loadingAnimationSecondaryColor: "#eeeeee",
99
+ loadingAnimationDuration: 3000,
100
+ });
101
+
102
+ const statusText = root.querySelector(
103
+ ".persona-tool-loading-shimmer-color"
104
+ ) as HTMLElement | null;
105
+ expect(statusText).not.toBeNull();
106
+ expect(
107
+ statusText!.style.getPropertyValue("--persona-tool-anim-color")
108
+ ).toBe("#111111");
109
+ expect(
110
+ statusText!.style.getPropertyValue("--persona-tool-anim-secondary-color")
111
+ ).toBe("#eeeeee");
112
+ expect(
113
+ statusText!.style.getPropertyValue("--persona-tool-anim-duration")
114
+ ).toBe("3000ms");
115
+ });
116
+
117
+ it("carries the artifact-card class so radius/border/background come from CSS", () => {
118
+ const root = render(streamingProps);
119
+
120
+ // Standalone card must not hardcode the rounded-xl utility class.
121
+ expect(root.classList.contains("persona-rounded-xl")).toBe(false);
122
+ // Radius/border/background now live in widget.css keyed off this class,
123
+ // following the --persona-artifact-card-radius → assistant-bubble chain.
124
+ expect(root.classList.contains("persona-artifact-card")).toBe(true);
125
+ });
126
+
127
+ it("keeps border/background off inline styles so they come from CSS", () => {
128
+ const root = render(streamingProps);
129
+
130
+ expect(root.classList.contains("persona-artifact-card")).toBe(true);
131
+ // Border/bg/radius/cursor moved to widget.css keyed off the card class.
132
+ expect(root.style.border).toBe("");
133
+ expect(root.style.background).toBe("");
134
+ expect(root.style.backgroundColor).toBe("");
135
+ expect(root.style.borderRadius).toBe("");
136
+ });
137
+
138
+ it("renders the Download button as a label button carrying the artifact id", () => {
139
+ const root = render({
140
+ artifactId: "a1",
141
+ title: "index.html",
142
+ status: "complete",
143
+ artifactType: "markdown",
144
+ file: { path: "index.html", mimeType: "text/html", language: "html" },
145
+ });
146
+
147
+ const dl = root.querySelector(
148
+ "[data-download-artifact]"
149
+ ) as HTMLButtonElement | null;
150
+ expect(dl).not.toBeNull();
151
+ expect(dl!.tagName).toBe("BUTTON");
152
+ expect(dl!.classList.contains("persona-label-btn")).toBe(true);
153
+ expect(dl!.textContent).toBe("Download");
154
+ expect(dl!.getAttribute("data-download-artifact")).toBe("a1");
155
+ });
156
+
157
+ it("renders the complete state with a Download button and no animation", () => {
158
+ const root = render({
159
+ artifactId: "a1",
160
+ title: "index.html",
161
+ status: "complete",
162
+ artifactType: "markdown",
163
+ file: { path: "index.html", mimeType: "text/html", language: "html" },
164
+ });
165
+
166
+ expect(findStatusText(root)).toBeNull();
167
+ expect(root.querySelector(".persona-tool-char")).toBeNull();
168
+
169
+ const dl = root.querySelector(
170
+ "[data-download-artifact]"
171
+ ) as HTMLButtonElement | null;
172
+ expect(dl).not.toBeNull();
173
+ expect(dl!.textContent).toBe("Download");
174
+ // Subtitle shows the file type label, not a "Generating" status.
175
+ expect(root.textContent).not.toContain("Generating");
176
+ });
177
+ });
178
+
179
+ describe("PersonaArtifactCard statusLabel", () => {
180
+ // Reconstruct the (possibly char-span animated) status label text.
181
+ const labelText = (root: HTMLElement): string => {
182
+ const label = root.querySelector(
183
+ ".persona-artifact-status-label"
184
+ ) as HTMLElement | null;
185
+ if (!label) return "";
186
+ const chars = label.querySelectorAll(".persona-tool-char");
187
+ if (chars.length === 0) return label.textContent ?? "";
188
+ return Array.from(chars)
189
+ .map((c) => (c.textContent === NBSP ? " " : c.textContent))
190
+ .join("");
191
+ };
192
+
193
+ it("replaces the default status text with a plain string", () => {
194
+ const root = render(streamingProps, { statusLabel: "Cooking up your file..." });
195
+ expect(labelText(root)).toBe("Cooking up your file...");
196
+ expect(root.textContent).not.toContain("Generating");
197
+ });
198
+
199
+ it("calls a statusLabel function with the card surface and ctx", () => {
200
+ const calls: PersonaArtifactStatusLabelContext[] = [];
201
+ const root = render(streamingProps, {
202
+ statusLabel: (ctx) => {
203
+ calls.push(ctx);
204
+ return "Custom label";
205
+ },
206
+ });
207
+ expect(calls.length).toBe(1);
208
+ expect(calls[0].surface).toBe("card");
209
+ expect(calls[0].artifactId).toBe("a1");
210
+ expect(calls[0].artifactType).toBe("markdown");
211
+ // file: index.html -> fileTypeLabel "HTML".
212
+ expect(calls[0].typeLabel).toBe("HTML");
213
+ expect(typeof calls[0].content).toBe("function");
214
+ expect(labelText(root)).toBe("Custom label");
215
+ });
216
+
217
+ it("renders a { label, detail } as an animated label + plain detail span", () => {
218
+ const root = render(streamingProps, {
219
+ statusLabel: () => ({ label: "Writing", detail: "just started" }),
220
+ });
221
+ expect(labelText(root)).toBe("Writing");
222
+ const detail = root.querySelector(
223
+ ".persona-artifact-status-detail"
224
+ ) as HTMLElement | null;
225
+ expect(detail).not.toBeNull();
226
+ expect(detail!.textContent).toBe("just started");
227
+ // The detail span is un-animated (no char spans / loading classes).
228
+ expect(detail!.querySelector(".persona-tool-char")).toBeNull();
229
+ });
230
+
231
+ it("falls back to the default label when statusLabel throws", () => {
232
+ const root = render(streamingProps, {
233
+ statusLabel: () => {
234
+ throw new Error("boom");
235
+ },
236
+ });
237
+ expect(labelText(root)).toBe("Generating html...");
238
+ });
239
+ });
240
+
241
+ describe("PersonaArtifactCard custom card actions", () => {
242
+ const completeProps = {
243
+ artifactId: "a1",
244
+ title: "index.html",
245
+ status: "complete",
246
+ artifactType: "markdown",
247
+ markdown: "# Hi",
248
+ file: { path: "index.html", mimeType: "text/html", language: "html" },
249
+ };
250
+
251
+ it("renders a card action button before Download on complete cards", () => {
252
+ const root = render(completeProps, {
253
+ cardActions: [
254
+ { id: "save", label: "Save to Drive", icon: "star", onClick: () => {} },
255
+ ],
256
+ });
257
+
258
+ const btn = root.querySelector(
259
+ '[data-artifact-custom-action="save"]'
260
+ ) as HTMLButtonElement | null;
261
+ expect(btn).not.toBeNull();
262
+ expect(btn!.tagName).toBe("BUTTON");
263
+ // Delegated in ui.ts: the card carries the id attribute, no direct listener.
264
+ expect(btn!.getAttribute("data-artifact-custom-action")).toBe("save");
265
+
266
+ const dl = root.querySelector(
267
+ "[data-download-artifact]"
268
+ ) as HTMLElement | null;
269
+ expect(dl).not.toBeNull();
270
+ // Custom actions render before Download.
271
+ expect(
272
+ btn!.compareDocumentPosition(dl!) & Node.DOCUMENT_POSITION_FOLLOWING
273
+ ).toBeTruthy();
274
+ });
275
+
276
+ it("does not render card actions while streaming", () => {
277
+ const root = render(streamingProps, {
278
+ cardActions: [
279
+ { id: "save", label: "Save to Drive", icon: "star", onClick: () => {} },
280
+ ],
281
+ });
282
+ expect(root.querySelector("[data-artifact-custom-action]")).toBeNull();
283
+ });
284
+
285
+ it("suppresses a card action whose visible() returns false", () => {
286
+ const root = render(completeProps, {
287
+ cardActions: [
288
+ {
289
+ id: "save",
290
+ label: "Save",
291
+ icon: "star",
292
+ visible: () => false,
293
+ onClick: () => {},
294
+ },
295
+ ],
296
+ });
297
+ expect(root.querySelector("[data-artifact-custom-action]")).toBeNull();
298
+ });
299
+ });
300
+
301
+ describe("componentRegistry registration options", () => {
302
+ const noop: ComponentRenderer = () => document.createElement("div");
303
+
304
+ afterEach(() => {
305
+ componentRegistry.unregister("OptTest");
306
+ });
307
+
308
+ it("registers the built-in artifact card with bubbleChrome:false", () => {
309
+ expect(componentRegistry.getOptions("PersonaArtifactCard")?.bubbleChrome).toBe(false);
310
+ });
311
+
312
+ it("returns the options passed to register", () => {
313
+ componentRegistry.register("OptTest", noop, { bubbleChrome: false });
314
+ expect(componentRegistry.getOptions("OptTest")).toEqual({ bubbleChrome: false });
315
+ });
316
+
317
+ it("returns undefined when a component is registered without options", () => {
318
+ componentRegistry.register("OptTest", noop);
319
+ expect(componentRegistry.getOptions("OptTest")).toBeUndefined();
320
+ });
321
+
322
+ it("clears prior options when re-registered without options", () => {
323
+ componentRegistry.register("OptTest", noop, { bubbleChrome: false });
324
+ componentRegistry.register("OptTest", noop);
325
+ expect(componentRegistry.getOptions("OptTest")).toBeUndefined();
326
+ });
327
+
328
+ it("drops options on unregister", () => {
329
+ componentRegistry.register("OptTest", noop, { bubbleChrome: false });
330
+ componentRegistry.unregister("OptTest");
331
+ expect(componentRegistry.getOptions("OptTest")).toBeUndefined();
332
+ });
333
+ });
@@ -1,4 +1,14 @@
1
1
  import type { ComponentContext, ComponentRenderer } from "./registry";
2
+ import type { PersonaArtifactFileMeta, PersonaArtifactRecord } from "../types";
3
+ import { fileTypeLabel, basenameOf } from "../utils/artifact-file";
4
+ import {
5
+ applyArtifactStatus,
6
+ clearArtifactStatusTracking,
7
+ resolveArtifactStatusLabel
8
+ } from "../utils/artifact-status-label";
9
+ import { createLabelButton } from "../utils/buttons";
10
+ import { buildArtifactActionButton } from "../utils/artifact-custom-actions";
11
+ import type { PersonaArtifactActionContext } from "../types";
2
12
 
3
13
  /**
4
14
  * Default artifact card renderer.
@@ -6,26 +16,32 @@ import type { ComponentContext, ComponentRenderer } from "./registry";
6
16
  */
7
17
  function renderDefaultArtifactCard(
8
18
  props: Record<string, unknown>,
9
- _context: ComponentContext
19
+ context: ComponentContext
10
20
  ): HTMLElement {
11
- const title =
21
+ const file =
22
+ props.file && typeof props.file === "object" && !Array.isArray(props.file)
23
+ ? (props.file as PersonaArtifactFileMeta)
24
+ : undefined;
25
+ const rawTitle =
12
26
  typeof props.title === "string" && props.title
13
27
  ? props.title
14
28
  : "Untitled artifact";
29
+ // File artifacts show the basename (title stays the full path on the wire).
30
+ const title = file ? basenameOf(file.path) : rawTitle;
15
31
  const artifactId =
16
32
  typeof props.artifactId === "string" ? props.artifactId : "";
17
33
  const status = props.status === "streaming" ? "streaming" : "complete";
18
34
  const artifactType =
19
35
  typeof props.artifactType === "string" ? props.artifactType : "markdown";
20
- const subtitle =
21
- artifactType === "component" ? "Component" : "Document";
36
+ const subtitle = file
37
+ ? fileTypeLabel(file)
38
+ : artifactType === "component"
39
+ ? "Component"
40
+ : "Document";
22
41
 
23
42
  const root = document.createElement("div");
24
43
  root.className =
25
- "persona-flex persona-w-full persona-max-w-full persona-items-center persona-gap-3 persona-rounded-xl persona-px-4 persona-py-3";
26
- root.style.border = "1px solid var(--persona-border, #e5e7eb)";
27
- root.style.backgroundColor = "var(--persona-surface, #ffffff)";
28
- root.style.cursor = "pointer";
44
+ "persona-artifact-card persona-flex persona-w-full persona-max-w-full persona-items-center persona-gap-3 persona-px-4 persona-py-3";
29
45
  root.tabIndex = 0;
30
46
  root.setAttribute("role", "button");
31
47
  root.setAttribute("aria-label", `Open ${title} in artifact panel`);
@@ -56,35 +72,66 @@ function renderDefaultArtifactCard(
56
72
  subtitleEl.style.color = "var(--persona-muted, #9ca3af)";
57
73
 
58
74
  if (status === "streaming") {
59
- // Pulsing dot for streaming status
60
- const dot = document.createElement("span");
61
- dot.className = "persona-inline-block persona-w-1.5 persona-h-1.5 persona-rounded-full";
62
- dot.style.backgroundColor = "var(--persona-primary, #171717)";
63
- dot.style.animation = "persona-pulse 1.5s ease-in-out infinite";
64
- subtitleEl.appendChild(dot);
65
-
66
- const statusText = document.createElement("span");
67
- statusText.textContent = `Generating ${subtitle.toLowerCase()}...`;
68
- subtitleEl.appendChild(statusText);
75
+ const artifactsCfg = context?.config?.features?.artifacts;
76
+ // The card re-renders only at streaming-start and completion (deltas flow to
77
+ // the pane / inline block, not here), so live counters don't tick on the
78
+ // card; the string / label form still applies. Build a minimal record from
79
+ // the card props so the shared resolver derives the same typeLabel + ctx.
80
+ const record: PersonaArtifactRecord = {
81
+ id: artifactId,
82
+ artifactType: artifactType === "component" ? "component" : "markdown",
83
+ title: rawTitle,
84
+ status: "streaming",
85
+ ...(typeof props.markdown === "string" ? { markdown: props.markdown } : {}),
86
+ ...(file ? { file } : {})
87
+ };
88
+ const resolved = resolveArtifactStatusLabel(record, artifactsCfg, "card");
89
+ applyArtifactStatus(subtitleEl, resolved, artifactsCfg);
69
90
  } else {
91
+ if (artifactId) clearArtifactStatusTracking(artifactId);
70
92
  subtitleEl.textContent = subtitle;
71
93
  }
72
94
 
73
95
  meta.append(titleEl, subtitleEl);
74
96
  root.append(iconBox, meta);
75
97
 
76
- // Download button (visible when artifact is complete)
98
+ // Custom actions + Download button render only on complete artifacts, same
99
+ // as Download: content-dependent actions during streaming would act on
100
+ // partial content, so they wait until the artifact is fully materialized.
77
101
  if (status === "complete") {
78
- const dl = document.createElement("button");
79
- dl.type = "button";
80
- dl.textContent = "Download";
102
+ // Custom card actions carry NO direct listeners (the card re-renders during
103
+ // streaming and is morphed by idiomorph). Clicks are handled by event
104
+ // delegation in ui.ts, keyed off data-artifact-custom-action, mirroring the
105
+ // Download button's data-download-artifact delegation.
106
+ const cardActions = context?.config?.features?.artifacts?.cardActions;
107
+ if (cardActions && cardActions.length > 0) {
108
+ const ctx: PersonaArtifactActionContext = {
109
+ artifactId: artifactId || null,
110
+ title,
111
+ artifactType,
112
+ markdown:
113
+ typeof props.markdown === "string" ? props.markdown : undefined,
114
+ file,
115
+ };
116
+ for (const action of cardActions) {
117
+ try {
118
+ if (action.visible === undefined || action.visible(ctx)) {
119
+ const btn = buildArtifactActionButton(action);
120
+ btn.setAttribute("data-artifact-custom-action", action.id);
121
+ btn.className = `${btn.className} persona-flex-shrink-0`;
122
+ root.append(btn);
123
+ }
124
+ } catch {
125
+ // A single bad action must not take down the whole card.
126
+ }
127
+ }
128
+ }
129
+
130
+ const dl = createLabelButton({
131
+ label: "Download",
132
+ className: "persona-flex-shrink-0",
133
+ });
81
134
  dl.title = `Download ${title}`;
82
- dl.className =
83
- "persona-flex-shrink-0 persona-rounded-md persona-px-3 persona-py-1.5 persona-text-xs persona-font-medium";
84
- dl.style.border = "1px solid var(--persona-border, #e5e7eb)";
85
- dl.style.color = "var(--persona-text, #1f2937)";
86
- dl.style.backgroundColor = "transparent";
87
- dl.style.cursor = "pointer";
88
135
  dl.setAttribute("data-download-artifact", artifactId);
89
136
  root.append(dl);
90
137
  }