@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
@@ -202,6 +202,16 @@ export interface PanelTokens extends ComponentTokenSet {
202
202
  canvasBackground?: string;
203
203
  }
204
204
 
205
+ /** Per-element text styling shared by themable text surfaces. */
206
+ export interface TextStyleTokens {
207
+ fontFamily?: string;
208
+ fontSize?: string;
209
+ fontWeight?: string;
210
+ lineHeight?: string;
211
+ letterSpacing?: string;
212
+ color?: TokenReference<'color'>;
213
+ }
214
+
205
215
  export interface HeaderTokens extends ComponentTokenSet {
206
216
  background: TokenReference<'color'>;
207
217
  border: TokenReference<'color'>;
@@ -210,10 +220,14 @@ export interface HeaderTokens extends ComponentTokenSet {
210
220
  iconBackground: TokenReference<'color'>;
211
221
  /** Foreground (glyph stroke or emoji text) on the header avatar tile. */
212
222
  iconForeground: TokenReference<'color'>;
213
- /** Header title line (next to the icon, or minimal layout title). */
223
+ /** Legacy alias of `title.color`; `title.color` wins when both are set. */
214
224
  titleForeground: TokenReference<'color'>;
215
- /** Header subtitle line under the title. */
225
+ /** Legacy alias of `subtitle.color`; `subtitle.color` wins when both are set. */
216
226
  subtitleForeground: TokenReference<'color'>;
227
+ /** Header title typography (next to the icon, or minimal layout title). */
228
+ title?: TextStyleTokens;
229
+ /** Header subtitle typography, for the line under the title. */
230
+ subtitle?: TextStyleTokens;
217
231
  /** Default color for clear / close icon buttons when launcher overrides are unset. */
218
232
  actionIconForeground: TokenReference<'color'>;
219
233
  /** Box-shadow on the header (e.g., a fade shadow to replace the default border). */
@@ -245,7 +259,7 @@ export interface MessageTokens {
245
259
 
246
260
  /**
247
261
  * Welcome / intro card rendered above the message list when no messages exist.
248
- * Set `copy.showWelcomeCard: false` to hide it; use `layout.slots["body-top"]`
262
+ * Set `welcome.variant: "none"` to hide it; use `layout.slots["body-top"]`
249
263
  * to replace it wholesale.
250
264
  */
251
265
  export interface IntroCardTokens extends ComponentTokenSet {
@@ -254,6 +268,12 @@ export interface IntroCardTokens extends ComponentTokenSet {
254
268
  padding?: TokenReference<'spacing'>;
255
269
  /** Box-shadow on the intro card (token ref or raw CSS, e.g. `none`). */
256
270
  shadow?: string;
271
+ /** Full border shorthand on the intro card (raw CSS, e.g. `"1px solid rgba(0,0,0,0.1)"`). @default "none" */
272
+ border?: string;
273
+ /** Welcome title typography. */
274
+ title?: TextStyleTokens;
275
+ /** Welcome subtitle typography. */
276
+ subtitle?: TextStyleTokens;
257
277
  }
258
278
 
259
279
  /** Collapsible widget chrome (tool bubbles, reasoning bubbles, approval bubbles). */
@@ -298,6 +318,8 @@ export interface MarkdownTokens {
298
318
  background?: TokenReference<'color'>;
299
319
  borderColor?: TokenReference<'color'>;
300
320
  textColor?: TokenReference<'color'>;
321
+ /** Corner radius; follows `palette.radius.md` by default so square-corner themes get square code blocks. */
322
+ borderRadius?: TokenReference<'radius'>;
301
323
  };
302
324
  /** Table styling. */
303
325
  table?: {
@@ -361,10 +383,20 @@ export interface ReasoningBubbleTokens {
361
383
  shadow: string;
362
384
  }
363
385
 
386
+ /** Scrollbar appearance shared by every scroller in the widget. */
387
+ export interface ScrollbarTokens {
388
+ /** Thumb color. @default semantic.colors.border */
389
+ thumb?: TokenReference<'color'>;
390
+ /** Track color. @default "transparent" */
391
+ track?: TokenReference<'color'>;
392
+ }
393
+
364
394
  /** Composer (message input) chrome. */
365
395
  export interface ComposerChromeTokens {
366
396
  /** Box-shadow on the composer form (raw CSS, e.g. `none`). */
367
397
  shadow: string;
398
+ /** Border color of the composer form. @default semantic.colors.border */
399
+ borderColor?: TokenReference<'color'>;
368
400
  /** Inner padding of the composer form (raw CSS shorthand). @default "0.75rem 1rem" */
369
401
  padding?: string;
370
402
  /** Gap between the textarea row and the actions row. @default "0.5rem" */
@@ -534,7 +566,10 @@ export interface ScrollToBottomTokens extends ComponentTokenSet {
534
566
 
535
567
  /** Visual tokens shared by one suggestion presentation variant. */
536
568
  export interface SuggestionVariantTokens extends ComponentTokenSet {
569
+ /** Space inside one item, between its icon and its copy. */
537
570
  gap?: string;
571
+ /** Space between suggestion items in the container. */
572
+ itemGap?: string;
538
573
  minHeight?: string;
539
574
  fontSize?: string;
540
575
  lineHeight?: string;
@@ -579,6 +614,8 @@ export interface ComponentTokens {
579
614
  toolBubble: ToolBubbleTokens;
580
615
  reasoningBubble: ReasoningBubbleTokens;
581
616
  composer: ComposerChromeTokens;
617
+ /** Scrollbar appearance for every scroller in the widget. */
618
+ scrollbar?: ScrollbarTokens;
582
619
  /** Icon button styling tokens. */
583
620
  iconButton?: IconButtonTokens;
584
621
  /** Label button styling tokens. */
package/src/types.ts CHANGED
@@ -1251,6 +1251,13 @@ export type AgentWidgetArtifactsLayoutConfig = {
1251
1251
  * @default 520
1252
1252
  */
1253
1253
  narrowHostMaxWidth?: number;
1254
+ /**
1255
+ * Width of the slide-over artifact drawer (the narrow-host and mobile
1256
+ * presentations; `paneWidth`/`paneMaxWidth` only size the desktop split).
1257
+ * CSS length; `"100%"` makes the drawer cover the whole panel.
1258
+ * @default min(calc(100% - 1rem), 22rem)
1259
+ */
1260
+ drawerWidth?: string;
1254
1261
  /**
1255
1262
  * When true (default), widen the launcher panel while artifacts are visible and not user-dismissed.
1256
1263
  * No-op for inline embed (`launcher.enabled === false`).
@@ -1894,6 +1901,19 @@ export type AgentWidgetScrollMode = "follow" | "anchor-top" | "none";
1894
1901
  */
1895
1902
  export type AgentWidgetScrollRestorePosition = "bottom" | "last-user-turn";
1896
1903
 
1904
+ /**
1905
+ * Scrollbar visibility policy for every scroll surface in the widget.
1906
+ *
1907
+ * - `"on-scroll"` hides bars at rest and reveals them on genuine reader
1908
+ * input, keeping them visible while the reader is away from the resting
1909
+ * position: overlay-scrollbar semantics on every platform.
1910
+ * - `"auto"` keeps the themed bar under native visibility semantics: overlay
1911
+ * platforms still auto-hide, classic-scrollbar platforms (Windows,
1912
+ * always-show macOS) show it persistently.
1913
+ * - `"hidden"` never shows an indicator. Scrolling itself is unaffected.
1914
+ */
1915
+ export type AgentWidgetScrollbarPolicy = "on-scroll" | "auto" | "hidden";
1916
+
1897
1917
  export type AgentWidgetScrollBehaviorFeature = {
1898
1918
  /** Scroll behavior during streamed responses. @default "anchor-top" */
1899
1919
  mode?: AgentWidgetScrollMode;
@@ -1938,6 +1958,13 @@ export type AgentWidgetScrollBehaviorFeature = {
1938
1958
  * @default false
1939
1959
  */
1940
1960
  announce?: boolean;
1961
+ /**
1962
+ * Scrollbar visibility policy, applied to the transcript and every inner
1963
+ * scroller through one root attribute. Themable via
1964
+ * `--persona-scrollbar-thumb` and `--persona-scrollbar-track`.
1965
+ * @default "on-scroll"
1966
+ */
1967
+ scrollbar?: AgentWidgetScrollbarPolicy;
1941
1968
  };
1942
1969
 
1943
1970
  export type AgentWidgetScrollToBottomFeature = {
@@ -2915,6 +2942,58 @@ export type AgentWidgetLauncherConfig = {
2915
2942
  * @example "min(380px, calc(100vw - 48px))"
2916
2943
  */
2917
2944
  collapsedMaxWidth?: string;
2945
+ /**
2946
+ * Proactive nudge bubble anchored above the collapsed floating launcher.
2947
+ * Omit it (the default) and no teaser renders.
2948
+ */
2949
+ teaser?: AgentWidgetLauncherTeaserConfig;
2950
+ };
2951
+
2952
+ /**
2953
+ * Launcher teaser: a small bubble above the collapsed launcher. Clicking it
2954
+ * opens the panel and consumes the teaser exactly like an explicit dismissal.
2955
+ * Within a single page load the teaser appears at most once under either
2956
+ * frequency.
2957
+ *
2958
+ * @example
2959
+ * ```typescript
2960
+ * launcher: {
2961
+ * teaser: { text: "Need a hand picking a plan?", delayMs: 3000 }
2962
+ * }
2963
+ * ```
2964
+ */
2965
+ export type AgentWidgetLauncherTeaserConfig = {
2966
+ /** Bubble copy. An empty string renders no teaser. */
2967
+ text: string;
2968
+ /**
2969
+ * Delay before the bubble appears, in milliseconds.
2970
+ * @default 0
2971
+ */
2972
+ delayMs?: number;
2973
+ /**
2974
+ * How often the teaser may return.
2975
+ *
2976
+ * `"once"`: a dismissal or a click-through is remembered in `localStorage`
2977
+ * under `` `${persistState.keyPrefix ?? "persona-"}teaser-dismissed` `` and
2978
+ * the teaser never returns on that browser. `"always"`: shows on every page
2979
+ * load, tracked in memory only. `persistState: false` downgrades `"once"` to
2980
+ * in-memory too, so the teaser reappears each load.
2981
+ *
2982
+ * Custom `storageAdapter` implementations do not participate in teaser state.
2983
+ *
2984
+ * @default "once"
2985
+ */
2986
+ frequency?: "once" | "always";
2987
+ /**
2988
+ * Render an x affordance that dismisses the teaser without opening the panel.
2989
+ * @default true
2990
+ */
2991
+ dismissible?: boolean;
2992
+ /**
2993
+ * Accessible label for the dismiss affordance.
2994
+ * @default "Dismiss message"
2995
+ */
2996
+ dismissLabel?: string;
2918
2997
  };
2919
2998
 
2920
2999
  export type AgentWidgetSendButtonConfig = {
@@ -3753,7 +3832,10 @@ export type AgentWidgetSuggestion =
3753
3832
  label: string;
3754
3833
  /** Text placed in the composer or sent. Defaults to `label`. */
3755
3834
  prompt?: string;
3756
- /** Optional supporting copy, most useful with the `card` variant. */
3835
+ /**
3836
+ * Optional supporting copy, most useful with the `card` variant. The
3837
+ * `chip` variant ignores it and renders the label alone.
3838
+ */
3757
3839
  description?: string;
3758
3840
  /** Optional icon from Persona's bundled Lucide registry. */
3759
3841
  icon?: IconName;
@@ -3800,9 +3882,10 @@ export type AgentWidgetSuggestionSurfaceConfig = {
3800
3882
  /** Default click behavior, overridable per item. Defaults to `send`. */
3801
3883
  behavior?: AgentWidgetSuggestionBehavior;
3802
3884
  /**
3803
- * Layout for items past the surface width. Defaults to `wrap` for starters
3804
- * and `scroll` for follow-ups. Applies to the `chip` variant only; the
3805
- * `card` and `list` layouts manage their own stacking.
3885
+ * Layout for items past the surface width. Defaults to `wrap` on every
3886
+ * surface; `scroll` opts into a horizontal strip for large sets. Applies
3887
+ * to the `chip` variant only; the `card` and `list` layouts manage their
3888
+ * own stacking.
3806
3889
  */
3807
3890
  overflow?: "scroll" | "wrap";
3808
3891
  /** Maximum visible items. Defaults to 4. */
@@ -3860,6 +3943,66 @@ export type AgentWidgetSuggestionsConfig = {
3860
3943
  followUps?: AgentWidgetFollowUpSuggestionsConfig;
3861
3944
  };
3862
3945
 
3946
+ /**
3947
+ * Which welcome surface renders.
3948
+ * `"card"` is the top-of-scroll intro card (default, back-compatible),
3949
+ * `"hero"` centers it in the empty conversation, `"none"` renders no surface.
3950
+ */
3951
+ export type AgentWidgetWelcomeVariant = "card" | "hero" | "none";
3952
+
3953
+ /**
3954
+ * When the welcome surface goes away. `"never"` is the default for `"card"`;
3955
+ * `"on-first-message"` is the default for `"hero"` and cannot be overridden
3956
+ * there.
3957
+ */
3958
+ export type AgentWidgetWelcomeDismiss = "never" | "on-first-message";
3959
+
3960
+ /**
3961
+ * Avatar or logo shown above the welcome title. Discriminated union; the
3962
+ * function form is the escape hatch for custom markup. There is deliberately
3963
+ * no raw HTML string variant (sanitization surface plus accessibility hole).
3964
+ */
3965
+ export type AgentWidgetWelcomeIcon =
3966
+ | { type: "lucide"; name: IconName }
3967
+ /** `alt` is required, and may be `""` for a decorative logo. */
3968
+ | { type: "image"; url: string; alt: string }
3969
+ /** Emoji or short text glyph. */
3970
+ | { type: "text"; text: string }
3971
+ | (() => HTMLElement | SVGElement);
3972
+
3973
+ /**
3974
+ * First-open welcome surface. Consolidates the deprecated
3975
+ * `copy.welcomeTitle` / `copy.welcomeSubtitle` / `copy.showWelcomeCard`
3976
+ * options; a field present here wins over its legacy alias.
3977
+ *
3978
+ * All defaults live in the resolver (`resolveWelcomeConfig`), never in
3979
+ * `DEFAULT_WIDGET_CONFIG`: presence on this object is what distinguishes a
3980
+ * host-set value from a filled-in default.
3981
+ */
3982
+ export interface AgentWidgetWelcomeConfig {
3983
+ /** Card or hero title. Defaults to "Hello 👋". */
3984
+ title?: string;
3985
+ /** Scope statement in the assistant's voice. Empty string omits it. */
3986
+ subtitle?: string;
3987
+ /** Avatar or logo shown above the title. */
3988
+ icon?: AgentWidgetWelcomeIcon;
3989
+ /** @default "card" */
3990
+ variant?: AgentWidgetWelcomeVariant;
3991
+ /** @default "never" for `card`, always `"on-first-message"` for `hero`. */
3992
+ dismiss?: AgentWidgetWelcomeDismiss;
3993
+ /**
3994
+ * Display-only greeting bubble pinned at transcript position zero. It is UI
3995
+ * chrome derived from config, never a session message: it does not appear in
3996
+ * `getMessages()`, is never persisted or sent to the model, `onMessage` does
3997
+ * not fire for it, and it survives `clearChat()`. Use an assistant
3998
+ * `initialMessages` entry instead when the model should see the greeting.
3999
+ *
4000
+ * Ignored (with a debug-mode warning) when `variant` is `"hero"`: the hero
4001
+ * is the greeting.
4002
+ */
4003
+ message?: string;
4004
+ }
4005
+
3863
4006
  /**
3864
4007
  * Interface for pluggable stream parsers that extract text from streaming responses.
3865
4008
  * Parsers handle incremental parsing to extract text values from structured formats (JSON, XML, etc.).
@@ -4152,7 +4295,9 @@ export type AgentWidgetHeaderLayoutConfig = {
4152
4295
  */
4153
4296
  render?: (context: HeaderRenderContext) => HTMLElement;
4154
4297
  /**
4155
- * Shown after the title in `minimal` header layout (e.g. chevron menu affordance).
4298
+ * Icon action buttons at the trailing edge of the `minimal` header layout,
4299
+ * clustered with the close button and sharing its chrome. For a menu
4300
+ * affordance next to the title, use `titleMenu` instead.
4156
4301
  */
4157
4302
  trailingActions?: AgentWidgetHeaderTrailingAction[];
4158
4303
  /** Called when a `trailingActions` button is clicked. */
@@ -4348,7 +4493,11 @@ export type AgentWidgetLayoutConfig = {
4348
4493
  /**
4349
4494
  * Max width for the content area (messages + composer).
4350
4495
  * Applied with `margin: 0 auto` for centering.
4351
- * Accepts any CSS width value (e.g. "90ch", "720px", "80%").
4496
+ * Accepts any CSS width value (e.g. "90ch", "720px", "80%"), or "none"
4497
+ * for a full-width column. Only engages on panels wider than the cap,
4498
+ * so launcher-width panels are unaffected.
4499
+ * @default "768px" ("720px" in composer-bar mode via
4500
+ * `launcher.composerBar.contentMaxWidth`)
4352
4501
  */
4353
4502
  contentMaxWidth?: string;
4354
4503
  };
@@ -5108,7 +5257,9 @@ export type AgentWidgetConfig = {
5108
5257
  */
5109
5258
  getHeaders?: AgentWidgetHeadersFunction;
5110
5259
  copy?: {
5260
+ /** @deprecated Use `welcome.title`. */
5111
5261
  welcomeTitle?: string;
5262
+ /** @deprecated Use `welcome.subtitle`. */
5112
5263
  welcomeSubtitle?: string;
5113
5264
  inputPlaceholder?: string;
5114
5265
  sendButtonLabel?: string;
@@ -5116,6 +5267,7 @@ export type AgentWidgetConfig = {
5116
5267
  stopButtonLabel?: string;
5117
5268
  /**
5118
5269
  * When false, the welcome / intro card is not shown above the message list.
5270
+ * @deprecated Use `welcome.variant: "none"`.
5119
5271
  * @default true
5120
5272
  */
5121
5273
  showWelcomeCard?: boolean;
@@ -5182,6 +5334,12 @@ export type AgentWidgetConfig = {
5182
5334
  initialSelectedArtifactId?: string | null;
5183
5335
  /** Rich starter and follow-up suggestion surfaces. */
5184
5336
  suggestions?: AgentWidgetSuggestionsConfig;
5337
+ /**
5338
+ * First-open welcome surface (card, hero, or none) plus the optional
5339
+ * display-only greeting bubble. Supersedes `copy.welcomeTitle`,
5340
+ * `copy.welcomeSubtitle`, and `copy.showWelcomeCard`.
5341
+ */
5342
+ welcome?: AgentWidgetWelcomeConfig;
5185
5343
  /** @deprecated Prefer `suggestions.starters.items`. */
5186
5344
  suggestionChips?: string[];
5187
5345
  /** @deprecated Prefer semantic tokens under `theme.components.suggestion`. */
@@ -0,0 +1,95 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+
7
+ const createMount = () => {
8
+ const mount = document.createElement("div");
9
+ document.body.appendChild(mount);
10
+ return mount;
11
+ };
12
+
13
+ describe("layout.slots['body-top']", () => {
14
+ afterEach(() => {
15
+ document.body.innerHTML = "";
16
+ vi.restoreAllMocks();
17
+ });
18
+
19
+ it("resolves defaultContent() to the intro card (regression: queried stale utility classes)", () => {
20
+ window.scrollTo = vi.fn();
21
+ const mount = createMount();
22
+ let resolved: HTMLElement | null | undefined;
23
+
24
+ createAgentExperience(mount, {
25
+ apiUrl: "https://api.example.com/chat",
26
+ launcher: { enabled: false },
27
+ layout: {
28
+ slots: {
29
+ "body-top": ({ defaultContent }) => {
30
+ resolved = defaultContent();
31
+ return null;
32
+ },
33
+ },
34
+ },
35
+ });
36
+
37
+ expect(resolved).not.toBeNull();
38
+ expect(resolved!.hasAttribute("data-persona-intro-card")).toBe(true);
39
+ });
40
+
41
+ it("replaces the intro card with the slot element instead of prepending beside it", () => {
42
+ window.scrollTo = vi.fn();
43
+ const mount = createMount();
44
+
45
+ createAgentExperience(mount, {
46
+ apiUrl: "https://api.example.com/chat",
47
+ launcher: { enabled: false },
48
+ layout: {
49
+ slots: {
50
+ "body-top": () => {
51
+ const custom = document.createElement("div");
52
+ custom.setAttribute("data-test-slot", "body-top");
53
+ return custom;
54
+ },
55
+ },
56
+ },
57
+ });
58
+
59
+ const body = mount.querySelector("#persona-scroll-container")!;
60
+ expect(body.querySelector("[data-test-slot='body-top']")).not.toBeNull();
61
+ expect(body.querySelector("[data-persona-intro-card]")).toBeNull();
62
+ expect(
63
+ (body.firstElementChild as HTMLElement).getAttribute("data-test-slot")
64
+ ).toBe("body-top");
65
+ });
66
+
67
+ it("supports composing around the default intro card", () => {
68
+ window.scrollTo = vi.fn();
69
+ const mount = createMount();
70
+
71
+ createAgentExperience(mount, {
72
+ apiUrl: "https://api.example.com/chat",
73
+ launcher: { enabled: false },
74
+ layout: {
75
+ slots: {
76
+ "body-top": ({ defaultContent }) => {
77
+ const wrapper = document.createElement("div");
78
+ wrapper.setAttribute("data-test-slot", "wrapper");
79
+ const banner = document.createElement("p");
80
+ banner.textContent = "Welcome back";
81
+ wrapper.appendChild(banner);
82
+ const card = defaultContent();
83
+ if (card) wrapper.appendChild(card);
84
+ return wrapper;
85
+ },
86
+ },
87
+ },
88
+ });
89
+
90
+ const body = mount.querySelector("#persona-scroll-container")!;
91
+ const wrapper = body.querySelector("[data-test-slot='wrapper']");
92
+ expect(wrapper).not.toBeNull();
93
+ expect(wrapper!.querySelector("[data-persona-intro-card]")).not.toBeNull();
94
+ });
95
+ });
@@ -0,0 +1,91 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+
7
+ const mounts: HTMLElement[] = [];
8
+ const controllers: ReturnType<typeof createAgentExperience>[] = [];
9
+
10
+ const makeController = (config: Record<string, unknown> = {}) => {
11
+ const mount = document.createElement("div");
12
+ document.body.appendChild(mount);
13
+ mounts.push(mount);
14
+ const controller = createAgentExperience(mount, {
15
+ apiUrl: "https://api.example.com/chat",
16
+ launcher: { enabled: false },
17
+ persistState: false,
18
+ suggestionChips: [],
19
+ ...config,
20
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
21
+ controllers.push(controller);
22
+ return { mount, controller };
23
+ };
24
+
25
+ const wrapper = (mount: HTMLElement) =>
26
+ mount.querySelector<HTMLElement>("#persona-scroll-container > .persona-widget-messages")!;
27
+
28
+ describe("default content column", () => {
29
+ afterEach(() => {
30
+ controllers.splice(0).forEach((controller) => controller.destroy());
31
+ mounts.splice(0).forEach((mount) => mount.remove());
32
+ document.body.innerHTML = "";
33
+ });
34
+
35
+ it("keeps the transcript wrapper :empty pre-conversation so CSS drops it from the flex flow", () => {
36
+ // The stylesheet's .persona-widget-messages:empty { display: none } only
37
+ // works if nothing seeds the wrapper with placeholder nodes.
38
+ const { mount, controller } = makeController();
39
+ const messagesWrapper = wrapper(mount);
40
+ expect(messagesWrapper.matches(":empty")).toBe(true);
41
+
42
+ controller.injectUserMessage({ content: "hi" });
43
+ expect(messagesWrapper.matches(":empty")).toBe(false);
44
+ });
45
+
46
+ it("caps and centers the transcript at 768px by default", () => {
47
+ const { mount } = makeController();
48
+ const messagesWrapper = wrapper(mount);
49
+ expect(messagesWrapper.style.maxWidth).toBe("768px");
50
+ expect(messagesWrapper.style.marginLeft).toBe("auto");
51
+ expect(messagesWrapper.style.marginRight).toBe("auto");
52
+ expect(messagesWrapper.style.width).toBe("100%");
53
+ });
54
+
55
+ it("honors an explicit layout.contentMaxWidth", () => {
56
+ const { mount } = makeController({ layout: { contentMaxWidth: "90ch" } });
57
+ expect(wrapper(mount).style.maxWidth).toBe("90ch");
58
+ });
59
+
60
+ it("opts out with \"none\" for a full-width column", () => {
61
+ const { mount } = makeController({ layout: { contentMaxWidth: "none" } });
62
+ expect(wrapper(mount).style.maxWidth).toBe("none");
63
+ });
64
+
65
+ it("caps the composer status text to the same column as the composer form", () => {
66
+ const { mount, controller } = makeController();
67
+ const statusText = mount.querySelector<HTMLElement>("[data-persona-composer-status]")!;
68
+ expect(statusText.style.maxWidth).toBe("768px");
69
+ expect(statusText.style.marginLeft).toBe("auto");
70
+ expect(statusText.style.marginRight).toBe("auto");
71
+
72
+ controller.update({ layout: { contentMaxWidth: "90ch" } });
73
+ expect(statusText.style.maxWidth).toBe("90ch");
74
+
75
+ controller.update({ layout: { contentMaxWidth: "" } });
76
+ expect(statusText.style.maxWidth).toBe("");
77
+ expect(statusText.style.marginLeft).toBe("");
78
+ });
79
+
80
+ it("publishes the resolved column as a CSS var for plugin content", () => {
81
+ const { mount, controller } = makeController();
82
+ expect(
83
+ mount.style.getPropertyValue("--persona-content-max-width")
84
+ ).toBe("768px");
85
+
86
+ controller.update({ layout: { contentMaxWidth: "90ch" } });
87
+ expect(
88
+ mount.style.getPropertyValue("--persona-content-max-width")
89
+ ).toBe("90ch");
90
+ });
91
+ });
@@ -27,24 +27,27 @@ describe("createAgentExperience docked mode", () => {
27
27
  });
28
28
 
29
29
  const wrapper = mount.firstElementChild as HTMLElement | null;
30
- const launcherButton = mount.lastElementChild as HTMLButtonElement | null;
30
+ // The launcher mounts inside its surface wrapper; visibility is driven on
31
+ // the wrapper so a teaser bubble hides with the button.
32
+ const launcherSurface = mount.lastElementChild as HTMLElement | null;
33
+ const launcherButton = launcherSurface?.querySelector("button") ?? null;
31
34
 
32
35
  expect(wrapper).not.toBeNull();
33
36
  expect(wrapper?.style.display).toBe("flex");
34
37
  expect(launcherButton).not.toBeNull();
35
38
  expect(launcherButton?.tagName).toBe("BUTTON");
36
- expect(launcherButton?.style.display).toBe("none");
39
+ expect(launcherSurface?.style.display).toBe("none");
37
40
  expect(launcherButton?.className).toContain("persona-mt-4");
38
41
 
39
42
  controller.close();
40
43
 
41
44
  expect(wrapper?.style.display).toBe("none");
42
- expect(launcherButton?.style.display).toBe("none");
45
+ expect(launcherSurface?.style.display).toBe("none");
43
46
 
44
47
  controller.open();
45
48
 
46
49
  expect(wrapper?.style.display).toBe("flex");
47
- expect(launcherButton?.style.display).toBe("none");
50
+ expect(launcherSurface?.style.display).toBe("none");
48
51
 
49
52
  controller.destroy();
50
53
  });