@runtypelabs/persona 4.15.0 → 4.17.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 (91) hide show
  1. package/dist/codegen.cjs +1 -1
  2. package/dist/codegen.js +1 -1
  3. package/dist/context-mentions.d.cts +283 -9
  4. package/dist/context-mentions.d.ts +283 -9
  5. package/dist/index.cjs +68 -68
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +299 -10
  8. package/dist/index.d.ts +299 -10
  9. package/dist/index.global.js +57 -57
  10. package/dist/index.global.js.map +1 -1
  11. package/dist/index.js +69 -69
  12. package/dist/index.js.map +1 -1
  13. package/dist/install.global.js +1 -1
  14. package/dist/install.global.js.map +1 -1
  15. package/dist/launcher.global.js +2 -2
  16. package/dist/launcher.global.js.map +1 -1
  17. package/dist/markdown-parsers.js +36 -36
  18. package/dist/smart-dom-reader.d.cts +283 -9
  19. package/dist/smart-dom-reader.d.ts +283 -9
  20. package/dist/testing.cjs +3 -3
  21. package/dist/testing.d.cts +7 -0
  22. package/dist/testing.d.ts +7 -0
  23. package/dist/testing.js +3 -3
  24. package/dist/theme-editor-preview.cjs +64 -64
  25. package/dist/theme-editor-preview.d.cts +283 -9
  26. package/dist/theme-editor-preview.d.ts +283 -9
  27. package/dist/theme-editor-preview.js +56 -56
  28. package/dist/theme-editor.cjs +10 -10
  29. package/dist/theme-editor.d.cts +283 -10
  30. package/dist/theme-editor.d.ts +283 -10
  31. package/dist/theme-editor.js +10 -10
  32. package/dist/theme-reference.cjs +1 -1
  33. package/dist/theme-reference.d.cts +66 -3
  34. package/dist/theme-reference.d.ts +66 -3
  35. package/dist/theme-reference.js +1 -1
  36. package/dist/widget.css +1 -1
  37. package/package.json +2 -2
  38. package/src/components/artifact-pane.test.ts +27 -0
  39. package/src/components/artifact-pane.ts +7 -3
  40. package/src/components/header-builder.ts +27 -2
  41. package/src/components/header-layouts.test.ts +56 -0
  42. package/src/components/header-layouts.ts +29 -9
  43. package/src/components/launcher-surface.test.ts +403 -0
  44. package/src/components/launcher.ts +297 -2
  45. package/src/components/panel.ts +69 -74
  46. package/src/components/suggestions.test.ts +40 -0
  47. package/src/components/suggestions.ts +213 -153
  48. package/src/components/welcome.test.ts +121 -0
  49. package/src/components/welcome.ts +305 -0
  50. package/src/components/widget-view.test.ts +5 -3
  51. package/src/components/widget-view.ts +14 -7
  52. package/src/defaults.ts +2 -2
  53. package/src/index-core.ts +12 -1
  54. package/src/install.test.ts +35 -1
  55. package/src/install.ts +47 -11
  56. package/src/launcher-global.ts +10 -7
  57. package/src/plugins/types.ts +66 -1
  58. package/src/styles/widget-styles.test.ts +220 -0
  59. package/src/styles/widget.css +353 -9
  60. package/src/testing/mock-stream.test.ts +19 -0
  61. package/src/testing/mock-stream.ts +20 -0
  62. package/src/theme-editor/sections.ts +30 -1
  63. package/src/theme-reference.ts +26 -4
  64. package/src/types/theme.ts +40 -3
  65. package/src/types.ts +164 -6
  66. package/src/ui.body-top-slot.test.ts +95 -0
  67. package/src/ui.content-width.test.ts +91 -0
  68. package/src/ui.docked.test.ts +7 -4
  69. package/src/ui.launcher-teaser.test.ts +162 -0
  70. package/src/ui.rebuild-composer.test.ts +491 -0
  71. package/src/ui.render-welcome.test.ts +505 -0
  72. package/src/ui.scroll-additive.test.ts +91 -21
  73. package/src/ui.scroll.test.ts +302 -14
  74. package/src/ui.suggest-replies.test.ts +46 -0
  75. package/src/ui.ts +747 -136
  76. package/src/ui.welcome.test.ts +504 -0
  77. package/src/utils/artifact-gate.test.ts +27 -0
  78. package/src/utils/artifact-gate.ts +9 -0
  79. package/src/utils/attachment-manager.test.ts +37 -0
  80. package/src/utils/attachment-manager.ts +15 -0
  81. package/src/utils/auto-follow.test.ts +23 -0
  82. package/src/utils/auto-follow.ts +11 -1
  83. package/src/utils/config-merge.ts +2 -0
  84. package/src/utils/content-width.test.ts +37 -0
  85. package/src/utils/content-width.ts +17 -0
  86. package/src/utils/plugin-storage.test.ts +94 -0
  87. package/src/utils/plugin-storage.ts +98 -0
  88. package/src/utils/theme.test.ts +137 -0
  89. package/src/utils/tokens.ts +49 -3
  90. package/src/welcome.test.ts +213 -0
  91. package/src/welcome.ts +130 -0
@@ -0,0 +1,305 @@
1
+ import { createElement, createNode } from "../utils/dom";
2
+ import { renderLucideIcon } from "../utils/icons";
3
+ import { getBubbleClasses } from "./message-bubble";
4
+ import type {
5
+ AgentWidgetConfig,
6
+ AgentWidgetWelcomeIcon,
7
+ } from "../types";
8
+ import type { ResolvedWelcomeConfig } from "../welcome";
9
+
10
+ /**
11
+ * Welcome surface DOM. The host is permanent: it mounts once with the panel
12
+ * and every variant/visibility change is applied to it in place, so live
13
+ * `controller.update()` never rebuilds it.
14
+ */
15
+
16
+ export interface WelcomeHostElements {
17
+ /** Permanent host; also carries `data-persona-intro-card` for slot parity. */
18
+ host: HTMLElement;
19
+ iconHolder: HTMLElement;
20
+ title: HTMLElement;
21
+ subtitle: HTMLElement;
22
+ starterSuggestions: HTMLElement;
23
+ }
24
+
25
+ const WELCOME_ICON_SIZE = 40;
26
+
27
+ /** Replaces the holder's content; hides it when no icon is configured. */
28
+ export const applyWelcomeIcon = (
29
+ holder: HTMLElement,
30
+ icon: AgentWidgetWelcomeIcon | undefined
31
+ ): void => {
32
+ holder.replaceChildren();
33
+ if (!icon) {
34
+ holder.hidden = true;
35
+ return;
36
+ }
37
+
38
+ if (typeof icon === "function") {
39
+ let custom: HTMLElement | SVGElement | null = null;
40
+ try {
41
+ custom = icon();
42
+ } catch (error) {
43
+ console.warn("[persona] welcome.icon renderer threw", error);
44
+ }
45
+ if (!custom) {
46
+ holder.hidden = true;
47
+ return;
48
+ }
49
+ holder.appendChild(custom);
50
+ holder.hidden = false;
51
+ return;
52
+ }
53
+
54
+ if (icon.type === "lucide") {
55
+ const svg = renderLucideIcon(icon.name, WELCOME_ICON_SIZE);
56
+ if (!svg) {
57
+ holder.hidden = true;
58
+ return;
59
+ }
60
+ holder.appendChild(svg);
61
+ } else if (icon.type === "image") {
62
+ holder.appendChild(
63
+ createNode("img", {
64
+ className: "persona-welcome-icon-image",
65
+ attrs: { src: icon.url, alt: icon.alt },
66
+ })
67
+ );
68
+ } else {
69
+ holder.appendChild(
70
+ createNode("span", {
71
+ className: "persona-welcome-icon-text",
72
+ text: icon.text,
73
+ })
74
+ );
75
+ }
76
+ holder.hidden = false;
77
+ };
78
+
79
+ /**
80
+ * Build the welcome host. `starterSuggestions` is created by the caller so the
81
+ * suggestion surface keeps its existing wiring.
82
+ */
83
+ export const buildWelcomeHost = (
84
+ config: AgentWidgetConfig | undefined,
85
+ starterSuggestions: HTMLElement,
86
+ options: { flatShadow?: boolean } = {}
87
+ ): WelcomeHostElements => {
88
+ const iconHolder = createNode("div", {
89
+ className: "persona-welcome-icon",
90
+ attrs: { "data-persona-welcome-icon": "" },
91
+ });
92
+ iconHolder.hidden = true;
93
+
94
+ // Typography flows through `components.introCard.title` / `.subtitle`;
95
+ // the CSS fallbacks reproduce the utility classes these replaced.
96
+ const title = createElement("h2", "persona-welcome-title");
97
+ const subtitle = createElement("p", "persona-welcome-subtitle");
98
+
99
+ // Background and box-shadow flow through the themable `components.introCard`
100
+ // tokens; both default to flat. Docked mode always stays flat. Padding comes
101
+ // from `--persona-intro-card-padding` (see widget.css), which zeroes its
102
+ // horizontal component when the card resolves flat so the text shares the
103
+ // content column's left edge instead of carrying a phantom card inset.
104
+ const host = createNode(
105
+ "div",
106
+ {
107
+ className: "persona-welcome persona-rounded-2xl",
108
+ attrs: {
109
+ "data-persona-welcome": "",
110
+ "data-persona-intro-card": "",
111
+ "data-persona-welcome-variant": "card",
112
+ },
113
+ style: {
114
+ background: "var(--persona-intro-card-bg, transparent)",
115
+ boxShadow: options.flatShadow
116
+ ? "none"
117
+ : "var(--persona-intro-card-shadow, none)",
118
+ border: "var(--persona-intro-card-border, none)",
119
+ },
120
+ },
121
+ iconHolder,
122
+ title,
123
+ subtitle,
124
+ starterSuggestions
125
+ );
126
+
127
+ return { host, iconHolder, title, subtitle, starterSuggestions };
128
+ };
129
+
130
+ /**
131
+ * Show/hide the permanent host. The body gap tightens when nothing renders
132
+ * there so a hidden welcome leaves no phantom section spacing.
133
+ */
134
+ export const applyWelcomeVisibility = (
135
+ body: HTMLElement,
136
+ host: HTMLElement,
137
+ visible: boolean
138
+ ): void => {
139
+ host.hidden = !visible;
140
+ host.style.display = visible ? "" : "none";
141
+ body.classList.toggle("persona-gap-6", visible);
142
+ body.classList.toggle("persona-gap-3", !visible);
143
+ };
144
+
145
+ /**
146
+ * Apply resolved config to the permanent host. Every field is set on every
147
+ * call so `controller.update()` can drop a field and see it revert.
148
+ */
149
+ export const applyWelcomeConfig = (
150
+ elements: WelcomeHostElements,
151
+ resolved: ResolvedWelcomeConfig
152
+ ): void => {
153
+ elements.title.textContent = resolved.title;
154
+ elements.subtitle.textContent = resolved.subtitle;
155
+ // An empty subtitle omits the paragraph entirely, margin included.
156
+ elements.subtitle.hidden = resolved.subtitle === "";
157
+ applyWelcomeIcon(elements.iconHolder, resolved.icon);
158
+ elements.host.setAttribute("data-persona-welcome-variant", resolved.variant);
159
+ elements.host.setAttribute("data-persona-welcome-dismiss", resolved.dismiss);
160
+ };
161
+
162
+ /** Marks the element a `renderWelcome` plugin returned. */
163
+ export const WELCOME_PLUGIN_CONTENT_ATTR = "data-persona-welcome-plugin";
164
+ const WELCOME_OVERLAY_ATTR = "data-persona-welcome-overlay";
165
+ const WELCOME_OVERLAY_BODY_CLASS = "persona-welcome-overlay-active";
166
+
167
+ // An absolute child of a scrolled scroller sits at the content origin, not
168
+ // the visible viewport, and overflow: hidden freezes scrollTop rather than
169
+ // resetting it. Zero the scroll while the overlay is up, restore on clear.
170
+ const overlayScrollTop = new WeakMap<HTMLElement, number>();
171
+
172
+ /**
173
+ * Mount plugin content inside the permanent host. The default children are
174
+ * hidden by CSS while the content attribute is set, so nothing about their
175
+ * own hidden state has to be saved and restored.
176
+ */
177
+ export const mountWelcomePluginContent = (
178
+ body: HTMLElement,
179
+ host: HTMLElement,
180
+ element: HTMLElement
181
+ ): void => {
182
+ element.setAttribute(WELCOME_PLUGIN_CONTENT_ATTR, "");
183
+ host.appendChild(element);
184
+ host.setAttribute("data-persona-welcome-content", "plugin");
185
+ // Plugin content renders regardless of derived visibility and overlays the
186
+ // transcript, which is what makes "return to home" over a conversation work.
187
+ host.setAttribute(WELCOME_OVERLAY_ATTR, "");
188
+ if (!body.classList.contains(WELCOME_OVERLAY_BODY_CLASS)) {
189
+ overlayScrollTop.set(body, body.scrollTop);
190
+ body.classList.add(WELCOME_OVERLAY_BODY_CLASS);
191
+ body.scrollTop = 0;
192
+ }
193
+ };
194
+
195
+ /** Remove plugin content and hand the host back to the default renderer. */
196
+ export const clearWelcomePluginContent = (
197
+ body: HTMLElement,
198
+ host: HTMLElement,
199
+ element: HTMLElement | null
200
+ ): void => {
201
+ if (element && element !== host) element.remove();
202
+ host.removeAttribute("data-persona-welcome-content");
203
+ host.removeAttribute(WELCOME_OVERLAY_ATTR);
204
+ if (body.classList.contains(WELCOME_OVERLAY_BODY_CLASS)) {
205
+ body.classList.remove(WELCOME_OVERLAY_BODY_CLASS);
206
+ body.scrollTop = overlayScrollTop.get(body) ?? body.scrollTop;
207
+ overlayScrollTop.delete(body);
208
+ }
209
+ };
210
+
211
+ /**
212
+ * Greeting host: a sibling above `messagesWrapper`, so it is pinned at
213
+ * transcript position zero without ever entering the morph target.
214
+ */
215
+ export const buildGreetingHost = (): HTMLElement => {
216
+ const host = createNode("div", {
217
+ className: "persona-welcome-greeting persona-flex persona-flex-col",
218
+ attrs: { "data-persona-welcome-greeting": "" },
219
+ });
220
+ host.hidden = true;
221
+ return host;
222
+ };
223
+
224
+ /**
225
+ * Render the display-only greeting bubble. Plain text by contract: no
226
+ * markdown pipeline, no action affordances, no message id, never a session
227
+ * message.
228
+ */
229
+ export const renderWelcomeGreeting = (
230
+ host: HTMLElement,
231
+ text: string | undefined,
232
+ config: AgentWidgetConfig | undefined
233
+ ): void => {
234
+ host.replaceChildren();
235
+ if (!text) {
236
+ host.hidden = true;
237
+ return;
238
+ }
239
+
240
+ const layout = config?.layout?.messages?.layout ?? "bubble";
241
+ const bubble = createNode("div", {
242
+ className: getBubbleClasses("assistant", layout).join(" "),
243
+ attrs: { "data-persona-theme-zone": "assistant-message" },
244
+ text,
245
+ style:
246
+ layout === "flat"
247
+ ? {
248
+ backgroundColor: "transparent",
249
+ color: "var(--persona-message-assistant-text, var(--persona-text))",
250
+ }
251
+ : {
252
+ backgroundColor:
253
+ "var(--persona-message-assistant-bg, var(--persona-container))",
254
+ color: "var(--persona-message-assistant-text, var(--persona-text))",
255
+ },
256
+ });
257
+
258
+ host.appendChild(createNode("div", { className: "persona-flex" }, bubble));
259
+ host.hidden = false;
260
+ };
261
+
262
+ const prefersReducedMotion = (): boolean =>
263
+ typeof window !== "undefined" &&
264
+ typeof window.matchMedia === "function" &&
265
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
266
+
267
+ /**
268
+ * Hero dismiss. WAAPI, not a CSS transition: morph re-renders strip
269
+ * post-render CSS state and cancel transitions mid-stream. Shares the
270
+ * starters' entrance motion spec so the two read as one system.
271
+ *
272
+ * Returns the animation so the caller can hold the host visible while it runs
273
+ * and cancel it on a re-show. `onFinished` receives it, or null when the motion
274
+ * was skipped and the callback ran synchronously.
275
+ */
276
+ export const animateWelcomeOut = (
277
+ host: HTMLElement,
278
+ onFinished: (animation: Animation | null) => void
279
+ ): Animation | null => {
280
+ if (
281
+ prefersReducedMotion() ||
282
+ typeof host.animate !== "function" ||
283
+ !host.isConnected
284
+ ) {
285
+ onFinished(null);
286
+ return null;
287
+ }
288
+ // `forwards`, not `backwards`: backwards only fills the delay phase, so the
289
+ // element snaps back to opacity 1 for a frame after the keyframes end.
290
+ const animation = host.animate(
291
+ [
292
+ { opacity: "1", transform: "none" },
293
+ { opacity: "0", transform: "translateY(-4px)" },
294
+ ],
295
+ { duration: 180, easing: "ease-out", fill: "forwards" }
296
+ );
297
+ let settled = false;
298
+ const settle = () => {
299
+ if (settled) return;
300
+ settled = true;
301
+ onFinished(animation);
302
+ };
303
+ animation.finished.then(settle).catch(settle);
304
+ return animation;
305
+ };
@@ -129,15 +129,17 @@ describe("createWidgetView: replacement helpers", () => {
129
129
  describe("resolveLauncher", () => {
130
130
  const config: AgentWidgetConfig = { apiUrl: "/api" };
131
131
 
132
- it("builds the default launcher controller and wires onToggle", () => {
132
+ it("builds the default launcher surface and wires onToggle", () => {
133
133
  const onToggle = vi.fn();
134
134
  const { instance, element } = resolveLauncher({ config, plugins: [], onToggle });
135
135
 
136
136
  expect(instance).not.toBeNull();
137
137
  expect(element).toBe(instance?.element);
138
- expect(element.tagName).toBe("BUTTON");
138
+ // The mounted element is the surface wrapper; the button is inside it.
139
+ expect(element.tagName).toBe("DIV");
140
+ expect(instance?.launcher.element.tagName).toBe("BUTTON");
139
141
 
140
- element.dispatchEvent(new Event("click"));
142
+ instance!.launcher.element.dispatchEvent(new Event("click"));
141
143
  expect(onToggle).toHaveBeenCalledTimes(1);
142
144
  });
143
145
 
@@ -2,7 +2,7 @@ import { AgentWidgetConfig } from "../types";
2
2
  import { AgentWidgetPlugin } from "../plugins/types";
3
3
  import { createWrapper, buildPanel, PanelElements } from "./panel";
4
4
  import { HeaderElements } from "./header-builder";
5
- import { createLauncherButton, LauncherButton } from "./launcher";
5
+ import { createLauncherButton, createLauncherSurface, LauncherSurface } from "./launcher";
6
6
 
7
7
  /**
8
8
  * Widget view assembly layer.
@@ -34,6 +34,9 @@ export interface WidgetTranscriptRefs {
34
34
  transcriptSuggestions: HTMLElement;
35
35
  /** Absolute slot above the composer where interactive sheets mount. */
36
36
  composerOverlay: HTMLElement;
37
+ welcomeHost: HTMLElement;
38
+ welcomeIconHolder: HTMLElement;
39
+ greetingHost: HTMLElement;
37
40
  introTitle: HTMLElement;
38
41
  introSubtitle: HTMLElement;
39
42
  }
@@ -124,6 +127,9 @@ export const createWidgetView = ({
124
127
  starterSuggestions: panelElements.starterSuggestions,
125
128
  transcriptSuggestions: panelElements.transcriptSuggestions,
126
129
  composerOverlay: panelElements.composerOverlay,
130
+ welcomeHost: panelElements.welcomeHost,
131
+ welcomeIconHolder: panelElements.welcomeIconHolder,
132
+ greetingHost: panelElements.greetingHost,
127
133
  introTitle: panelElements.introTitle,
128
134
  introSubtitle: panelElements.introSubtitle,
129
135
  };
@@ -199,11 +205,11 @@ export interface ResolveLauncherOptions {
199
205
 
200
206
  export interface ResolvedLauncher {
201
207
  /**
202
- * The default launcher controller (with `update`/`destroy`), or null when a
208
+ * The default launcher surface (with `update`/`destroy`), or null when a
203
209
  * plugin supplied a custom launcher element.
204
210
  */
205
- instance: LauncherButton | null;
206
- /** The element to mount: either the default button or the plugin's element. */
211
+ instance: LauncherSurface | null;
212
+ /** The element to mount: either the default surface or the plugin's element. */
207
213
  element: HTMLElement;
208
214
  }
209
215
 
@@ -213,8 +219,9 @@ export interface ResolvedLauncher {
213
219
  * both the initial-build and re-enable call sites in ui.ts.
214
220
  *
215
221
  * When a plugin returns a custom element, `instance` is null (the plugin owns
216
- * its own updates); otherwise the default `LauncherButton` controller is
217
- * returned so the caller can drive `update()`/`destroy()`.
222
+ * its own updates, including any teaser); otherwise the default
223
+ * `LauncherSurface` controller is returned so the caller can drive
224
+ * `update()`/`destroy()`.
218
225
  */
219
226
  export const resolveLauncher = ({
220
227
  config,
@@ -233,6 +240,6 @@ export const resolveLauncher = ({
233
240
  }
234
241
  }
235
242
 
236
- const instance = createLauncherButton(config, onToggle);
243
+ const instance = createLauncherSurface(config, onToggle);
237
244
  return { instance, element: instance.element };
238
245
  };
package/src/defaults.ts CHANGED
@@ -79,8 +79,8 @@ export const DEFAULT_WIDGET_CONFIG: Partial<AgentWidgetConfig> = {
79
79
  colorScheme: "light",
80
80
  launcher: DEFAULT_LAUNCHER_CONFIG,
81
81
  copy: {
82
- welcomeTitle: "Hello 👋",
83
- welcomeSubtitle: "Ask anything about your account or products.",
82
+ // No welcomeTitle / welcomeSubtitle here on purpose: `resolveWelcomeConfig`
83
+ // owns those defaults so presence still means "the host set this".
84
84
  inputPlaceholder: "How can I help...",
85
85
  sendButtonLabel: "Send",
86
86
  },
package/src/index-core.ts CHANGED
@@ -31,6 +31,7 @@ export type {
31
31
  AgentWidgetInitOptions,
32
32
  AgentWidgetMessage,
33
33
  AgentWidgetLauncherConfig,
34
+ AgentWidgetLauncherTeaserConfig,
34
35
  AgentWidgetDockConfig,
35
36
  AgentWidgetEvent,
36
37
  AgentWidgetStreamParser,
@@ -151,7 +152,11 @@ export type {
151
152
  AgentWidgetSuggestionSurfaceConfig,
152
153
  AgentWidgetStarterSuggestionsConfig,
153
154
  AgentWidgetFollowUpSuggestionsConfig,
154
- AgentWidgetSuggestionsConfig
155
+ AgentWidgetSuggestionsConfig,
156
+ AgentWidgetWelcomeConfig,
157
+ AgentWidgetWelcomeIcon,
158
+ AgentWidgetWelcomeVariant,
159
+ AgentWidgetWelcomeDismiss
155
160
  } from "./types";
156
161
 
157
162
  export {
@@ -341,8 +346,13 @@ export type {
341
346
  AgentWidgetPlugin,
342
347
  AgentWidgetTransformSuggestionsContext,
343
348
  AgentWidgetRenderSuggestionContext,
349
+ AgentWidgetRenderWelcomeContext,
344
350
  AgentWidgetSuggestionSelectContext
345
351
  } from "./plugins/types";
352
+ // Welcome resolver: `renderWelcome` receives the resolved shape.
353
+ export { resolveWelcomeConfig, isWelcomeVisible } from "./welcome";
354
+ export type { ResolvedWelcomeConfig } from "./welcome";
355
+ export type { AgentWidgetPluginStorage } from "./utils/plugin-storage";
346
356
  export { pluginRegistry } from "./plugins/registry";
347
357
 
348
358
  // Stream animation plugin API: lets consumers register custom animations
@@ -447,6 +457,7 @@ export type {
447
457
  SemanticSpacing,
448
458
  SemanticTypography,
449
459
  ComponentTokens,
460
+ TextStyleTokens,
450
461
  ArtifactToolbarTokens,
451
462
  ArtifactTabTokens,
452
463
  ArtifactPaneTokens,
@@ -404,12 +404,46 @@ describe("install.ts: CDN base derivation", () => {
404
404
  expect(cssHref).toBe("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@latest/dist/widget.css");
405
405
  });
406
406
 
407
- it("an explicit `version` opts back into npm-CDN URLs even when the src is derivable", async () => {
407
+ it("an explicit `version` on the first-party CDN stays first-party (versioned path)", async () => {
408
408
  setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
409
+ const { cssHref, scripts } = await installedUrls({ version: "1.2.3" });
410
+ expect(cssHref).toBe("https://cdn.runtype.com/persona/1.2.3/widget.css");
411
+ expect(scripts).toContain("https://cdn.runtype.com/persona/1.2.3/launcher.global.js");
412
+ });
413
+
414
+ it("an explicit `version` on a non-first-party src opts into npm-CDN URLs", async () => {
415
+ setCurrentScript("https://assets.example.com/vendor/persona/install.global.js");
409
416
  const { cssHref } = await installedUrls({ version: "1.2.3" });
410
417
  expect(cssHref).toBe("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@1.2.3/dist/widget.css");
411
418
  });
412
419
 
420
+ it("warns when resolved assets leave the installer's origin (silent-CSP guard)", async () => {
421
+ setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
422
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
423
+ await installedUrls({ cdn: "jsdelivr" });
424
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("strict CSP may silently block"));
425
+ warn.mockRestore();
426
+ });
427
+
428
+ it("does not warn when assets resolve to the installer's own origin", async () => {
429
+ setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
430
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
431
+ await installedUrls({ version: "1.2.3" });
432
+ expect(warn).not.toHaveBeenCalled();
433
+ warn.mockRestore();
434
+ });
435
+
436
+ it("does not warn for explicit cssUrl + jsUrl overrides on another origin", async () => {
437
+ setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
438
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
439
+ await installedUrls({
440
+ cssUrl: "https://assets.example.com/persona/widget.css",
441
+ jsUrl: "https://assets.example.com/persona/index.global.js",
442
+ });
443
+ expect(warn).not.toHaveBeenCalled();
444
+ warn.mockRestore();
445
+ });
446
+
413
447
  it("an explicit `cdn` opts back into npm-CDN URLs even when the src is derivable", async () => {
414
448
  setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
415
449
  const { cssHref } = await installedUrls({ cdn: "unpkg" });
package/src/install.ts CHANGED
@@ -7,9 +7,12 @@
7
7
  export {};
8
8
 
9
9
  interface SiteAgentInstallConfig {
10
- // Setting `version` or `cdn` explicitly opts into npm-CDN asset URLs
11
- // (jsDelivr/unpkg). When neither is set, assets load from the directory the
12
- // installer script itself was served from — see getCdnBase().
10
+ // Setting `cdn` explicitly opts into npm-CDN asset URLs (jsDelivr/unpkg),
11
+ // and `version` pins the asset version. When neither is set, assets load
12
+ // from the directory the installer script itself was served from. On the
13
+ // first-party CDN (cdn.runtype.com) a `version` stays first-party
14
+ // (…/persona/<version>/); elsewhere it opts into npm-CDN URLs. Prefer
15
+ // pinning via the installer script URL itself — see getCdnBase().
13
16
  version?: string;
14
17
  cdn?: "unpkg" | "jsdelivr";
15
18
  cssUrl?: string;
@@ -239,16 +242,30 @@ declare global {
239
242
 
240
243
  // Default: load assets from wherever the installer was served, so a page
241
244
  // whose CSP allows only its own CDN (e.g. cdn.runtype.com) never has
242
- // sibling requests fan out to a third-party host. An explicit `cdn` or
243
- // `version` keeps the documented npm-CDN behavior.
244
- if (!config.cdn && !config.version) {
245
+ // sibling requests fan out to a third-party host. An explicit `cdn`
246
+ // keeps the documented npm-CDN behavior.
247
+ if (!config.cdn) {
245
248
  const selfBase = getInstallerBase();
246
249
  if (selfBase) {
247
- return {
248
- cssUrl: `${selfBase}/widget.css`,
249
- jsUrl: `${selfBase}/index.global.js`,
250
- launcherUrl: `${selfBase}/launcher.global.js` as string | null,
251
- };
250
+ if (!config.version) {
251
+ return {
252
+ cssUrl: `${selfBase}/widget.css`,
253
+ jsUrl: `${selfBase}/index.global.js`,
254
+ launcherUrl: `${selfBase}/launcher.global.js` as string | null,
255
+ };
256
+ }
257
+ // A `version` on the first-party CDN stays first-party: the bucket
258
+ // mirrors every release at /persona/<version>/, and rerouting to a
259
+ // npm CDN would be blocked by the CSP that allowed the installer.
260
+ const selfOrigin = new URL(selfBase).origin;
261
+ if (selfOrigin === "https://cdn.runtype.com") {
262
+ const base = `${selfOrigin}/persona/${config.version}`;
263
+ return {
264
+ cssUrl: `${base}/widget.css`,
265
+ jsUrl: `${base}/index.global.js`,
266
+ launcherUrl: `${base}/launcher.global.js` as string | null,
267
+ };
268
+ }
252
269
  }
253
270
  }
254
271
 
@@ -268,6 +285,25 @@ declare global {
268
285
 
269
286
  const { cssUrl, jsUrl, launcherUrl } = getCdnBase();
270
287
 
288
+ // Surface silent CSP breakage: when `cdn`/`version` reroutes assets to a
289
+ // different origin than the one that served the installer, a strict CSP
290
+ // (e.g. Runtype-hosted apps) blocks them with no visible error. Explicit
291
+ // cssUrl/jsUrl overrides are a deliberate choice, so they don't warn.
292
+ if (!(config.cssUrl && config.jsUrl)) {
293
+ const selfBase = getInstallerBase();
294
+ try {
295
+ if (selfBase && new URL(selfBase).origin !== new URL(jsUrl, document.baseURI).origin) {
296
+ console.warn(
297
+ `[persona] Widget assets resolve to ${new URL(jsUrl, document.baseURI).origin}, but the installer loaded from ${new URL(selfBase).origin}. ` +
298
+ "A strict CSP may silently block them. To pin a version, put it in the installer script URL " +
299
+ "(e.g. https://cdn.runtype.com/persona/<version>/install.global.js) instead of the `version`/`cdn` config keys.",
300
+ );
301
+ }
302
+ } catch {
303
+ // URL parsing is best-effort; never let diagnostics break installation.
304
+ }
305
+ }
306
+
271
307
  // Check if CSS is already loaded
272
308
  const isCssLoaded = () => {
273
309
  return !!document.head.querySelector('link[data-persona]') ||
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Critical-path launcher entry: built to `launcher.global.js` (IIFE).
3
3
  *
4
- * Ships ONLY the real collapsed launcher (`createLauncherButton`) plus the
4
+ * Ships ONLY the real collapsed launcher surface (`createLauncherSurface`,
5
+ * which carries the optional launcher teaser) plus the
5
6
  * theme application path, so the launcher paints pixel-identically to the full
6
7
  * widget from a tiny bundle. The heavy conversation panel is deferred until
7
8
  * first open by the installer (Phase 2).
@@ -16,7 +17,7 @@
16
17
  * window.AgentWidgetLauncher.mount({ target, config, onOpen })
17
18
  * → { root, element, update, destroy }
18
19
  */
19
- import { createLauncherButton } from "./components/launcher";
20
+ import { createLauncherSurface } from "./components/launcher";
20
21
  import { applyThemeVariables } from "./utils/theme";
21
22
  import { DEFAULT_LAUNCHER_CONFIG } from "./defaults";
22
23
  import type { AgentWidgetConfig } from "./types";
@@ -100,20 +101,22 @@ export const mount = (
100
101
  root.setAttribute(CRITICAL_LAUNCHER_ATTR, "true");
101
102
  applyThemeVariables(root, config);
102
103
 
103
- const launcher = createLauncherButton(config, onOpen);
104
- root.appendChild(launcher.element);
104
+ const surface = createLauncherSurface(config, onOpen);
105
+ root.appendChild(surface.element);
105
106
  target.appendChild(root);
106
107
 
107
108
  return {
108
109
  root,
109
- element: launcher.element,
110
+ element: surface.launcher.element,
110
111
  update: (next: AgentWidgetConfig) => {
111
112
  const merged = mergeCriticalLauncherConfig(next);
112
113
  applyThemeVariables(root, merged);
113
- launcher.update(merged);
114
+ surface.update(merged);
114
115
  },
116
+ // Handoff destroys the whole surface, so a pending teaser timer cannot
117
+ // outlive the critical launcher.
115
118
  destroy: () => {
116
- launcher.destroy();
119
+ surface.destroy();
117
120
  root.remove();
118
121
  },
119
122
  };