@runtypelabs/persona 4.12.0 → 4.14.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 (68) hide show
  1. package/dist/codegen.cjs +1 -1
  2. package/dist/codegen.js +1 -1
  3. package/dist/context-mentions.d.cts +98 -6
  4. package/dist/context-mentions.d.ts +98 -6
  5. package/dist/index.cjs +64 -64
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +183 -8
  8. package/dist/index.d.ts +183 -8
  9. package/dist/index.global.js +54 -54
  10. package/dist/index.global.js.map +1 -1
  11. package/dist/index.js +67 -67
  12. package/dist/index.js.map +1 -1
  13. package/dist/launcher.global.js +1 -1
  14. package/dist/launcher.global.js.map +1 -1
  15. package/dist/smart-dom-reader.d.cts +98 -6
  16. package/dist/smart-dom-reader.d.ts +98 -6
  17. package/dist/theme-editor-preview.cjs +66 -66
  18. package/dist/theme-editor-preview.d.cts +100 -6
  19. package/dist/theme-editor-preview.d.ts +100 -6
  20. package/dist/theme-editor-preview.js +75 -75
  21. package/dist/theme-editor.cjs +10 -10
  22. package/dist/theme-editor.d.cts +134 -7
  23. package/dist/theme-editor.d.ts +134 -7
  24. package/dist/theme-editor.js +10 -10
  25. package/dist/theme-reference.cjs +1 -1
  26. package/dist/widget.css +1 -1
  27. package/package.json +1 -1
  28. package/src/artifacts-session.test.ts +106 -0
  29. package/src/client.test.ts +2 -2
  30. package/src/client.ts +4 -1
  31. package/src/components/artifact-card.ts +13 -2
  32. package/src/components/artifact-inline.ts +11 -2
  33. package/src/components/composer-builder.test.ts +2 -0
  34. package/src/components/composer-builder.ts +1 -1
  35. package/src/components/composer-parts.test.ts +2 -0
  36. package/src/components/composer-parts.ts +1 -1
  37. package/src/components/message-bubble.test.ts +7 -1
  38. package/src/components/message-bubble.ts +11 -8
  39. package/src/index-core.ts +34 -0
  40. package/src/session.ts +45 -4
  41. package/src/styles/widget.css +10 -1
  42. package/src/theme-editor/index.ts +5 -0
  43. package/src/theme-editor/presets.test.ts +43 -0
  44. package/src/theme-editor/presets.ts +101 -87
  45. package/src/theme-editor/sections.test.ts +39 -7
  46. package/src/theme-editor/sections.ts +60 -8
  47. package/src/theme-editor/state-preferences.test.ts +15 -0
  48. package/src/theme-editor/state.ts +46 -0
  49. package/src/theme-editor/types.ts +15 -1
  50. package/src/theme-editor/webmcp/tools.test.ts +13 -0
  51. package/src/theme-editor/webmcp/tools.ts +5 -0
  52. package/src/types.ts +139 -9
  53. package/src/ui.artifact-pane-gating.test.ts +5 -5
  54. package/src/ui.config-preferences.test.ts +166 -0
  55. package/src/ui.scroll-additive.test.ts +365 -0
  56. package/src/ui.scroll.test.ts +19 -6
  57. package/src/ui.ts +158 -53
  58. package/src/ui.typing-indicator.test.ts +135 -0
  59. package/src/utils/artifact-display.test.ts +180 -16
  60. package/src/utils/artifact-display.ts +155 -14
  61. package/src/utils/auto-follow.test.ts +38 -0
  62. package/src/utils/auto-follow.ts +35 -0
  63. package/src/utils/config-merge.test.ts +60 -0
  64. package/src/utils/config-merge.ts +6 -1
  65. package/src/utils/feature-preferences.test.ts +326 -0
  66. package/src/utils/feature-preferences.ts +498 -0
  67. package/src/utils/theme.test.ts +92 -0
  68. package/src/utils/tokens.ts +7 -7
@@ -255,4 +255,110 @@ describe("AgentWidgetSession artifacts", () => {
255
255
  });
256
256
  expect(session.getArtifactById(session.getArtifacts()[0].id)?.file).toEqual(file);
257
257
  });
258
+
259
+ it("uses exact media-type file rules and the producer preferredMode hint", () => {
260
+ const session = new AgentWidgetSession(
261
+ {
262
+ features: {
263
+ artifacts: {
264
+ enabled: true,
265
+ display: {
266
+ default: "card",
267
+ files: { byMediaType: { "text/html": "inline" } },
268
+ },
269
+ },
270
+ },
271
+ },
272
+ {
273
+ onMessagesChanged: () => {},
274
+ onStatusChanged: () => {},
275
+ onStreamingChanged: () => {},
276
+ onArtifactsState: () => {},
277
+ }
278
+ );
279
+ const rec = session.upsertArtifact({
280
+ id: "html-app",
281
+ artifactType: "markdown",
282
+ content: "<h1>App</h1>",
283
+ file: { path: "app.html", mimeType: "TEXT/HTML; charset=utf-8" },
284
+ });
285
+ const block = session.getMessages().find((message) => message.id === `artifact-ref-${rec.id}`);
286
+ expect(JSON.parse(block!.rawContent!).component).toBe("PersonaArtifactInline");
287
+
288
+ const hinted = session.upsertArtifact({
289
+ id: "hinted-doc",
290
+ artifactType: "markdown",
291
+ content: "# Live preview",
292
+ presentation: { preferredMode: "inline" },
293
+ });
294
+ const hintedBlock = session
295
+ .getMessages()
296
+ .find((message) => message.id === `artifact-ref-${hinted.id}`);
297
+ expect(JSON.parse(hintedBlock!.rawContent!).component).toBe(
298
+ "PersonaArtifactInline"
299
+ );
300
+ });
301
+
302
+ it("re-materializes existing artifact blocks after a live display update", () => {
303
+ const onMessagesChanged = vi.fn();
304
+ const session = new AgentWidgetSession(
305
+ { features: { artifacts: { enabled: true, display: "card" } } },
306
+ {
307
+ onMessagesChanged,
308
+ onStatusChanged: () => {},
309
+ onStreamingChanged: () => {},
310
+ onArtifactsState: () => {},
311
+ }
312
+ );
313
+ session.upsertArtifact({
314
+ id: "live",
315
+ artifactType: "component",
316
+ component: "Chart",
317
+ props: { values: [1, 2] },
318
+ });
319
+ session.upsertArtifact({
320
+ id: "live-markdown",
321
+ artifactType: "markdown",
322
+ content: "# Report",
323
+ });
324
+ const unmatchedRawContent = JSON.stringify({
325
+ component: "PersonaArtifactCard",
326
+ props: {
327
+ artifactId: "hydrated-without-record",
328
+ artifactType: "markdown",
329
+ status: "complete",
330
+ },
331
+ });
332
+ session.injectAssistantMessage({
333
+ id: "artifact-ref-hydrated-without-record",
334
+ content: "",
335
+ rawContent: unmatchedRawContent,
336
+ });
337
+ onMessagesChanged.mockClear();
338
+
339
+ session.updateConfig({
340
+ features: { artifacts: { enabled: true, display: "inline" } },
341
+ });
342
+
343
+ expect(onMessagesChanged).toHaveBeenCalledTimes(1);
344
+ const block = session.getMessages().find((message) => message.id === "artifact-ref-live");
345
+ const parsed = JSON.parse(block!.rawContent!);
346
+ expect(parsed.component).toBe("PersonaArtifactInline");
347
+ expect(parsed.props.component).toBe("Chart");
348
+ expect(parsed.props.componentProps).toEqual({ values: [1, 2] });
349
+ const markdownBlock = session
350
+ .getMessages()
351
+ .find((message) => message.id === "artifact-ref-live-markdown");
352
+ const parsedMarkdown = JSON.parse(markdownBlock!.rawContent!);
353
+ expect(parsedMarkdown.component).toBe("PersonaArtifactInline");
354
+ expect(parsedMarkdown.props.markdown).toBe("# Report");
355
+ expect(
356
+ session
357
+ .getMessages()
358
+ .find(
359
+ (message) =>
360
+ message.id === "artifact-ref-hydrated-without-record"
361
+ )?.rawContent
362
+ ).toBe(unmatchedRawContent);
363
+ });
258
364
  });
@@ -4505,10 +4505,10 @@ describe('AgentWidgetClient - Artifact display modes', () => {
4505
4505
  });
4506
4506
  });
4507
4507
 
4508
- it('resolves the mode per artifact type via display.byType', async () => {
4508
+ it('resolves the mode per artifact kind via display.byKind', async () => {
4509
4509
  const display: AgentWidgetArtifactsFeature['display'] = {
4510
4510
  default: 'card',
4511
- byType: { component: 'inline' },
4511
+ byKind: { component: 'inline' },
4512
4512
  };
4513
4513
 
4514
4514
  const mdEvents = await dispatchAndCollect(markdownArtifactFrames(), {
package/src/client.ts CHANGED
@@ -3184,7 +3184,10 @@ export class AgentWidgetClient {
3184
3184
  artifactIdsWithCards.add(artId);
3185
3185
  const displayMode = resolveArtifactDisplayMode(
3186
3186
  this.config.features?.artifacts,
3187
- at
3187
+ {
3188
+ artifactType: at,
3189
+ ...(artFile ? { file: artFile } : {}),
3190
+ }
3188
3191
  );
3189
3192
  const artComponent =
3190
3193
  typeof payload.component === "string" ? payload.component : undefined;
@@ -1,5 +1,9 @@
1
1
  import type { ComponentContext, ComponentRenderer } from "./registry";
2
- import type { PersonaArtifactFileMeta, PersonaArtifactRecord } from "../types";
2
+ import type {
3
+ PersonaArtifactFileMeta,
4
+ PersonaArtifactPresentation,
5
+ PersonaArtifactRecord
6
+ } from "../types";
3
7
  import { fileTypeLabel, basenameOf } from "../utils/artifact-file";
4
8
  import {
5
9
  applyArtifactStatus,
@@ -33,6 +37,12 @@ function renderDefaultArtifactCard(
33
37
  const status = props.status === "streaming" ? "streaming" : "complete";
34
38
  const artifactType =
35
39
  typeof props.artifactType === "string" ? props.artifactType : "markdown";
40
+ const presentation =
41
+ props.presentation &&
42
+ typeof props.presentation === "object" &&
43
+ !Array.isArray(props.presentation)
44
+ ? (props.presentation as PersonaArtifactPresentation)
45
+ : undefined;
36
46
  const subtitle = file
37
47
  ? fileTypeLabel(file)
38
48
  : artifactType === "component"
@@ -83,7 +93,8 @@ function renderDefaultArtifactCard(
83
93
  title: rawTitle,
84
94
  status: "streaming",
85
95
  ...(typeof props.markdown === "string" ? { markdown: props.markdown } : {}),
86
- ...(file ? { file } : {})
96
+ ...(file ? { file } : {}),
97
+ ...(presentation ? { presentation } : {})
87
98
  };
88
99
  const resolved = resolveArtifactStatusLabel(record, artifactsCfg, "card");
89
100
  applyArtifactStatus(subtitleEl, resolved, artifactsCfg);
@@ -2,6 +2,7 @@ import type { ComponentContext, ComponentRenderer } from "./registry";
2
2
  import type {
3
3
  AgentWidgetArtifactsFeature,
4
4
  PersonaArtifactFileMeta,
5
+ PersonaArtifactPresentation,
5
6
  PersonaArtifactRecord
6
7
  } from "../types";
7
8
  import { createElement } from "../utils/dom";
@@ -126,6 +127,12 @@ function recordFromProps(props: Record<string, unknown>): PersonaArtifactRecord
126
127
  const title =
127
128
  typeof props.title === "string" && props.title ? props.title : undefined;
128
129
  const status = props.status === "streaming" ? "streaming" : "complete";
130
+ const presentation =
131
+ props.presentation &&
132
+ typeof props.presentation === "object" &&
133
+ !Array.isArray(props.presentation)
134
+ ? (props.presentation as PersonaArtifactPresentation)
135
+ : undefined;
129
136
  if (props.artifactType === "component") {
130
137
  const embedded = props.componentProps;
131
138
  const componentProps =
@@ -138,7 +145,8 @@ function recordFromProps(props: Record<string, unknown>): PersonaArtifactRecord
138
145
  title,
139
146
  status,
140
147
  component: typeof props.component === "string" ? props.component : "",
141
- props: componentProps
148
+ props: componentProps,
149
+ ...(presentation ? { presentation } : {})
142
150
  };
143
151
  }
144
152
  const file =
@@ -151,7 +159,8 @@ function recordFromProps(props: Record<string, unknown>): PersonaArtifactRecord
151
159
  title,
152
160
  status,
153
161
  markdown: typeof props.markdown === "string" ? props.markdown : "",
154
- ...(file ? { file } : {})
162
+ ...(file ? { file } : {}),
163
+ ...(presentation ? { presentation } : {})
155
164
  };
156
165
  }
157
166
 
@@ -17,6 +17,8 @@ describe("buildComposer (full column-stacked composer)", () => {
17
17
  expect(elements.composerForm.tagName).toBe("FORM");
18
18
  expect(elements.composerForm.getAttribute("data-persona-composer-form")).toBe("");
19
19
  expect(elements.composerForm.classList.contains("persona-flex-col")).toBe(true);
20
+ expect(elements.composerForm.classList.contains("persona-border-persona-border")).toBe(true);
21
+ expect(elements.composerForm.classList.contains("persona-border-gray-200")).toBe(false);
20
22
 
21
23
  expect(elements.textarea.getAttribute("data-persona-composer-input")).toBe("");
22
24
  expect(elements.sendButton.getAttribute("data-persona-composer-submit")).toBe("");
@@ -67,7 +67,7 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
67
67
 
68
68
  const composerForm = createNode("form", {
69
69
  className:
70
- "persona-widget-composer persona-flex persona-flex-col persona-gap-2 persona-rounded-2xl persona-border persona-border-gray-200 persona-bg-persona-input-background persona-px-4 persona-py-3",
70
+ "persona-widget-composer persona-flex persona-flex-col persona-gap-2 persona-rounded-2xl persona-border persona-border-persona-border persona-bg-persona-input-background persona-px-4 persona-py-3",
71
71
  attrs: { "data-persona-composer-form": "" },
72
72
  style: { outline: "none" },
73
73
  });
@@ -19,6 +19,8 @@ describe("createComposerTextarea", () => {
19
19
  expect(textarea.tagName).toBe("TEXTAREA");
20
20
  expect(textarea.getAttribute("data-persona-composer-input")).toBe("");
21
21
  expect(textarea.classList.contains("persona-composer-textarea")).toBe(true);
22
+ expect(textarea.classList.contains("persona-text-persona-text")).toBe(true);
23
+ expect(textarea.classList.contains("persona-text-persona-primary")).toBe(false);
22
24
  });
23
25
 
24
26
  it("attachAutoResize wires an input listener that grows up to maxHeight", () => {
@@ -32,7 +32,7 @@ export const createComposerTextarea = (config?: AgentWidgetConfig): ComposerText
32
32
  textarea.setAttribute("data-persona-composer-input", "");
33
33
  textarea.placeholder = config?.copy?.inputPlaceholder ?? "Type your message…";
34
34
  textarea.className =
35
- "persona-w-full persona-min-h-[24px] persona-resize-none persona-border-none persona-bg-transparent persona-text-sm persona-text-persona-primary focus:persona-outline-none focus:persona-border-none persona-composer-textarea";
35
+ "persona-w-full persona-min-h-[24px] persona-resize-none persona-border-none persona-bg-transparent persona-text-sm persona-text-persona-text focus:persona-outline-none focus:persona-border-none persona-composer-textarea";
36
36
  textarea.rows = 1;
37
37
 
38
38
  textarea.style.fontFamily =
@@ -276,7 +276,7 @@ describe("createStandardBubble", () => {
276
276
  expect(userBubble.classList.contains("persona-bg-persona-accent")).toBe(true);
277
277
  expect(userBubble.classList.contains("persona-rounded-lg")).toBe(true);
278
278
  expect(userBubble.classList.contains("persona-px-3")).toBe(true);
279
- expect(assistantBubble.classList.contains("persona-bg-persona-surface")).toBe(
279
+ expect(assistantBubble.classList.contains("persona-bg-persona-container")).toBe(
280
280
  true
281
281
  );
282
282
  expect(assistantBubble.classList.contains("persona-rounded-lg")).toBe(true);
@@ -302,6 +302,12 @@ describe("createStandardBubble", () => {
302
302
 
303
303
  expect(userMessage.style.backgroundColor).toBe("transparent");
304
304
  expect(assistantMessage.style.backgroundColor).toBe("transparent");
305
+ expect(assistantMessage.style.color).toBe(
306
+ "var(--persona-message-assistant-text, var(--persona-text))"
307
+ );
308
+ expect(assistantMessage.classList.contains("persona-text-persona-text")).toBe(
309
+ true
310
+ );
305
311
  expect(userMessage.classList.contains("persona-px-3")).toBe(false);
306
312
  expect(assistantMessage.classList.contains("persona-px-3")).toBe(false);
307
313
  });
@@ -649,7 +649,7 @@ const createTimestamp = (
649
649
  /**
650
650
  * Get bubble classes based on layout preset
651
651
  */
652
- const getBubbleClasses = (
652
+ export const getBubbleClasses = (
653
653
  role: "user" | "assistant" | "system",
654
654
  layout: AgentWidgetMessageLayoutConfig["layout"] = "bubble"
655
655
  ): string[] => {
@@ -668,7 +668,7 @@ const getBubbleClasses = (
668
668
  } else {
669
669
  baseClasses.push(
670
670
  "persona-message-assistant-bubble",
671
- "persona-text-persona-primary",
671
+ "persona-text-persona-text",
672
672
  "persona-py-2"
673
673
  );
674
674
  }
@@ -693,8 +693,8 @@ const getBubbleClasses = (
693
693
  } else {
694
694
  baseClasses.push(
695
695
  "persona-message-assistant-bubble",
696
- "persona-bg-persona-surface",
697
- "persona-text-persona-primary",
696
+ "persona-bg-persona-container",
697
+ "persona-text-persona-text",
698
698
  "persona-px-3",
699
699
  "persona-py-2",
700
700
  "persona-rounded-lg"
@@ -723,10 +723,10 @@ const getBubbleClasses = (
723
723
  } else {
724
724
  baseClasses.push(
725
725
  "persona-message-assistant-bubble",
726
- "persona-bg-persona-surface",
726
+ "persona-bg-persona-container",
727
727
  "persona-border",
728
728
  "persona-border-persona-message-border",
729
- "persona-text-persona-primary",
729
+ "persona-text-persona-text",
730
730
  "persona-px-5",
731
731
  "persona-py-3"
732
732
  );
@@ -879,12 +879,15 @@ export const createStandardBubble = (
879
879
  // Apply component-level color overrides via CSS variables
880
880
  if (layout === "flat") {
881
881
  bubble.style.backgroundColor = "transparent";
882
- bubble.style.color = "var(--persona-primary, var(--persona-text))";
882
+ bubble.style.color =
883
+ message.role === "user"
884
+ ? "var(--persona-primary, var(--persona-text))"
885
+ : "var(--persona-message-assistant-text, var(--persona-text))";
883
886
  } else if (message.role === "user") {
884
887
  bubble.style.backgroundColor = 'var(--persona-message-user-bg, var(--persona-accent))';
885
888
  bubble.style.color = 'var(--persona-message-user-text, white)';
886
889
  } else if (message.role === "assistant") {
887
- bubble.style.backgroundColor = 'var(--persona-message-assistant-bg, var(--persona-surface))';
890
+ bubble.style.backgroundColor = 'var(--persona-message-assistant-bg, var(--persona-container))';
888
891
  bubble.style.color = 'var(--persona-message-assistant-text, var(--persona-text))';
889
892
  }
890
893
 
package/src/index-core.ts CHANGED
@@ -5,6 +5,8 @@ import {
5
5
 
6
6
  export type {
7
7
  AgentWidgetConfig,
8
+ AgentWidgetStorageAdapter,
9
+ AgentWidgetStoredState,
8
10
  AgentWidgetConfigPatch,
9
11
  TargetResolver,
10
12
  ResolvedTarget,
@@ -12,12 +14,19 @@ export type {
12
14
  AgentWidgetArtifactsFeature,
13
15
  AgentWidgetArtifactsLayoutConfig,
14
16
  PersonaArtifactDisplayMode,
17
+ PersonaArtifactDisplayModeInput,
18
+ PersonaArtifactDisplayRules,
19
+ PersonaArtifactFilesDisplayRules,
15
20
  PersonaArtifactKind,
21
+ PersonaArtifactPresentation,
16
22
  PersonaArtifactRecord,
17
23
  PersonaArtifactActionContext,
18
24
  PersonaArtifactStatusLabelContext,
19
25
  PersonaArtifactCustomAction,
20
26
  PersonaArtifactManualUpsert,
27
+ WidgetPreferenceCapabilities,
28
+ WidgetArtifactLayoutPreference,
29
+ WidgetPreferenceSlice,
21
30
  ArtifactConfigPayload,
22
31
  AgentWidgetInitOptions,
23
32
  AgentWidgetMessage,
@@ -134,6 +143,31 @@ export type {
134
143
  AgentWidgetAskUserQuestionStyles
135
144
  } from "./types";
136
145
 
146
+ export {
147
+ applyFeaturePreferences,
148
+ parseWidgetPreferenceSlice,
149
+ resolveConfigPreferences,
150
+ normalizeMediaType,
151
+ } from "./utils/feature-preferences";
152
+
153
+ export type {
154
+ WidgetPreferenceParseIssueCode,
155
+ WidgetPreferenceParseIssue,
156
+ WidgetPreferenceParseResult,
157
+ } from "./utils/feature-preferences";
158
+
159
+ export {
160
+ canonicalArtifactDisplayMode,
161
+ resolveArtifactDisplay,
162
+ resolveArtifactDisplayMode,
163
+ } from "./utils/artifact-display";
164
+
165
+ export type {
166
+ PersonaArtifactDisplayDescriptor,
167
+ PersonaArtifactDisplayMatch,
168
+ PersonaArtifactDisplayResolution,
169
+ } from "./utils/artifact-display";
170
+
137
171
  export type {
138
172
  RuntypeExecutionStreamEvent,
139
173
  RuntypeStreamEventOf,
package/src/session.ts CHANGED
@@ -858,7 +858,11 @@ export class AgentWidgetSession {
858
858
  }
859
859
 
860
860
  public updateConfig(next: AgentWidgetConfig) {
861
+ const previousArtifactDisplay = this.config.features?.artifacts?.display;
861
862
  const merged = { ...this.config, ...next };
863
+ const artifactDisplayChanged =
864
+ JSON.stringify(previousArtifactDisplay) !==
865
+ JSON.stringify(merged.features?.artifacts?.display);
862
866
 
863
867
  // Connection/request-shaping change (apiUrl, clientToken, webmcp, headers,
864
868
  // parser, agent, …) → full client rebuild. UI-only change (theme, copy,
@@ -870,6 +874,7 @@ export class AgentWidgetSession {
870
874
  if (!connectionConfigChanged(this.config, merged)) {
871
875
  this.config = merged;
872
876
  this.client.updateConfig(merged);
877
+ if (artifactDisplayChanged) this.refreshArtifactReferenceBlocks();
873
878
  return;
874
879
  }
875
880
 
@@ -886,6 +891,7 @@ export class AgentWidgetSession {
886
891
  const prevSSECallback = this.client.getSSEEventCallback();
887
892
  this.config = merged;
888
893
  this.client = new AgentWidgetClient(this.config);
894
+ if (artifactDisplayChanged) this.refreshArtifactReferenceBlocks();
889
895
  this.wireDefaultWebMcpConfirm();
890
896
  if (prevSSECallback) {
891
897
  this.client.setSSEEventCallback(prevSSECallback);
@@ -2735,7 +2741,8 @@ export class AgentWidgetSession {
2735
2741
  title: manual.title,
2736
2742
  status: "complete",
2737
2743
  markdown: manual.content,
2738
- ...(manual.file ? { file: manual.file } : {})
2744
+ ...(manual.file ? { file: manual.file } : {}),
2745
+ ...(manual.presentation ? { presentation: manual.presentation } : {})
2739
2746
  }
2740
2747
  : {
2741
2748
  id,
@@ -2743,7 +2750,8 @@ export class AgentWidgetSession {
2743
2750
  title: manual.title,
2744
2751
  status: "complete",
2745
2752
  component: manual.component,
2746
- props: manual.props ?? {}
2753
+ props: manual.props ?? {},
2754
+ ...(manual.presentation ? { presentation: manual.presentation } : {})
2747
2755
  };
2748
2756
  this.artifacts.set(id, rec);
2749
2757
  this.selectedArtifactId = id;
@@ -2757,7 +2765,7 @@ export class AgentWidgetSession {
2757
2765
  /**
2758
2766
  * Injects (or refreshes) the in-thread artifact block for a programmatically
2759
2767
  * upserted artifact, matching the streamed UX: the resolved display mode
2760
- * picks the component ("card"/"panel" → reference card, "inline" → inline
2768
+ * picks the component ("collapsed"/"panel" → reference card, "inline" → inline
2761
2769
  * preview).
2762
2770
  *
2763
2771
  * Unlike the streamed path (which embeds content on `artifact_complete`),
@@ -2774,7 +2782,7 @@ export class AgentWidgetSession {
2774
2782
  const refId = `artifact-ref-${rec.id}`;
2775
2783
  const displayMode = resolveArtifactDisplayMode(
2776
2784
  this.config.features?.artifacts,
2777
- rec.artifactType
2785
+ rec
2778
2786
  );
2779
2787
  const rawContent = buildArtifactRefRawContent(displayMode, {
2780
2788
  artifactId: rec.id,
@@ -2782,6 +2790,7 @@ export class AgentWidgetSession {
2782
2790
  artifactType: rec.artifactType,
2783
2791
  status: "complete",
2784
2792
  ...(rec.file ? { file: rec.file } : {}),
2793
+ ...(rec.presentation ? { presentation: rec.presentation } : {}),
2785
2794
  ...(rec.component ? { component: rec.component } : {}),
2786
2795
  ...(rec.props ? { componentProps: rec.props } : {}),
2787
2796
  ...(rec.markdown !== undefined ? { markdown: rec.markdown } : {})
@@ -2803,6 +2812,38 @@ export class AgentWidgetSession {
2803
2812
  });
2804
2813
  }
2805
2814
 
2815
+ /**
2816
+ * Re-materialize existing transcript artifact blocks after a live display
2817
+ * preference update. One messages callback keeps conversion atomic.
2818
+ */
2819
+ private refreshArtifactReferenceBlocks(): void {
2820
+ let changed = false;
2821
+ for (const rec of this.artifacts.values()) {
2822
+ const message = this.messages.find((candidate) => candidate.id === `artifact-ref-${rec.id}`);
2823
+ if (!message) continue;
2824
+ const displayMode = resolveArtifactDisplayMode(
2825
+ this.config.features?.artifacts,
2826
+ rec
2827
+ );
2828
+ const rawContent = buildArtifactRefRawContent(displayMode, {
2829
+ artifactId: rec.id,
2830
+ title: rec.title,
2831
+ artifactType: rec.artifactType,
2832
+ status: rec.status,
2833
+ ...(rec.file ? { file: rec.file } : {}),
2834
+ ...(rec.presentation ? { presentation: rec.presentation } : {}),
2835
+ ...(rec.component ? { component: rec.component } : {}),
2836
+ ...(rec.props ? { componentProps: rec.props } : {}),
2837
+ ...(rec.markdown !== undefined ? { markdown: rec.markdown } : {}),
2838
+ });
2839
+ if (message.rawContent === rawContent) continue;
2840
+ message.rawContent = rawContent;
2841
+ message.streaming = rec.status === "streaming";
2842
+ changed = true;
2843
+ }
2844
+ if (changed) this.callbacks.onMessagesChanged([...this.messages]);
2845
+ }
2846
+
2806
2847
  private clearArtifactState(): void {
2807
2848
  if (this.artifacts.size === 0 && this.selectedArtifactId === null) return;
2808
2849
  this.artifacts.clear();
@@ -294,6 +294,10 @@
294
294
  color: var(--persona-primary, #171717);
295
295
  }
296
296
 
297
+ .persona-text-persona-text {
298
+ color: var(--persona-text, #111827);
299
+ }
300
+
297
301
  .persona-text-persona-call-to-action {
298
302
  color: var(--persona-call-to-action, var(--persona-text-inverse, #ffffff));
299
303
  }
@@ -930,6 +934,11 @@
930
934
  box-shadow: none !important;
931
935
  }
932
936
 
937
+ .persona-composer-textarea::placeholder {
938
+ color: var(--persona-input-placeholder, var(--persona-text-muted, #9ca3af));
939
+ opacity: 1;
940
+ }
941
+
933
942
  /* Prevent iOS Safari from zooming on input focus (requires 16px minimum) */
934
943
  @media (hover: none) and (pointer: coarse) {
935
944
  .persona-composer-textarea {
@@ -4928,7 +4937,7 @@
4928
4937
 
4929
4938
  .persona-composer-contenteditable:empty::before {
4930
4939
  content: attr(data-placeholder);
4931
- color: var(--persona-placeholder, var(--persona-muted, #9ca3af));
4940
+ color: var(--persona-input-placeholder, var(--persona-text-muted, #9ca3af));
4932
4941
  pointer-events: none;
4933
4942
  }
4934
4943
 
@@ -43,6 +43,8 @@ export {
43
43
  COMPONENT_COLOR_SECTIONS,
44
44
  CONFIGURE_SECTIONS,
45
45
  CONFIGURE_SUB_GROUPS,
46
+ STREAM_AND_ARTIFACTS_SUB_GROUP,
47
+ ARTIFACT_DISPLAY_PREFERENCE_SECTION,
46
48
  ALL_TABS,
47
49
  scopeSection,
48
50
  findSection,
@@ -90,6 +92,9 @@ export type {
90
92
  TranscriptStreamFrame,
91
93
  BuildTranscriptStreamFramesOptions,
92
94
  } from './preview-utils';
95
+ // Type-only: the full preview implementation stays in
96
+ // `@runtypelabs/persona/theme-editor/preview`.
97
+ export type { CompareMode, PreviewShellMode } from './preview';
93
98
 
94
99
  // Role mappings (Interface Roles editor)
95
100
  export {
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { AgentWidgetConfig } from '../types';
3
+ import { getActiveTheme, themeToCssVariables } from '../utils/theme';
4
+ import { BUILT_IN_PRESETS, getThemeEditorPreset } from './presets';
5
+
6
+ describe('theme editor presets', () => {
7
+ it('offers the paired Persona default before the single-scheme presets', () => {
8
+ const preset = getThemeEditorPreset('persona-default');
9
+
10
+ expect(BUILT_IN_PRESETS[0]).toBe(preset);
11
+ expect(preset).toMatchObject({
12
+ name: 'Persona Default',
13
+ tags: ['light', 'dark', 'adaptive'],
14
+ preview: { surface: '#ffffff' },
15
+ darkPreview: { surface: '#1f2937' },
16
+ });
17
+ expect(preset?.darkTheme).toBeDefined();
18
+ });
19
+
20
+ it('resolves the paired default to distinct light and dark surfaces', () => {
21
+ const preset = getThemeEditorPreset('persona-default')!;
22
+ const config = {
23
+ theme: preset.theme,
24
+ darkTheme: preset.darkTheme,
25
+ } as AgentWidgetConfig;
26
+
27
+ const light = themeToCssVariables(getActiveTheme({ ...config, colorScheme: 'light' }));
28
+ const dark = themeToCssVariables(getActiveTheme({ ...config, colorScheme: 'dark' }));
29
+
30
+ expect(light).toMatchObject({
31
+ '--persona-surface': '#f9fafb',
32
+ '--persona-background': '#f9fafb',
33
+ '--persona-text': '#111827',
34
+ '--persona-border': '#e5e7eb',
35
+ });
36
+ expect(dark).toMatchObject({
37
+ '--persona-surface': '#1f2937',
38
+ '--persona-background': '#111827',
39
+ '--persona-text': '#f3f4f6',
40
+ '--persona-border': '#374151',
41
+ });
42
+ });
43
+ });