@runtypelabs/persona 4.14.0 → 4.16.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 (119) hide show
  1. package/README.md +4 -4
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-DveIaNx6.d.cts → types-BpWGm7_6.d.cts} +17 -0
  5. package/dist/animations/{types-DveIaNx6.d.ts → types-BpWGm7_6.d.ts} +17 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-QJ4LV2KW.js +1 -0
  9. package/dist/codegen.cjs +12 -11
  10. package/dist/codegen.js +14 -13
  11. package/dist/{context-mentions-ONG7A7M6.js → context-mentions-BTSTIBFC.js} +1 -1
  12. package/dist/context-mentions.cjs +11 -8
  13. package/dist/context-mentions.d.cts +520 -21
  14. package/dist/context-mentions.d.ts +520 -21
  15. package/dist/context-mentions.js +11 -8
  16. package/dist/index.cjs +69 -68
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +595 -46
  19. package/dist/index.d.ts +595 -46
  20. package/dist/index.global.js +60 -57
  21. package/dist/index.global.js.map +1 -1
  22. package/dist/index.js +69 -68
  23. package/dist/index.js.map +1 -1
  24. package/dist/launcher.global.js +5 -2
  25. package/dist/launcher.global.js.map +1 -1
  26. package/dist/smart-dom-reader.d.cts +520 -21
  27. package/dist/smart-dom-reader.d.ts +520 -21
  28. package/dist/testing.cjs +3 -3
  29. package/dist/testing.d.cts +7 -0
  30. package/dist/testing.d.ts +7 -0
  31. package/dist/testing.js +3 -3
  32. package/dist/theme-editor-preview.cjs +67 -67
  33. package/dist/theme-editor-preview.d.cts +534 -22
  34. package/dist/theme-editor-preview.d.ts +534 -22
  35. package/dist/theme-editor-preview.js +57 -57
  36. package/dist/theme-editor.cjs +10 -10
  37. package/dist/theme-editor.d.cts +521 -23
  38. package/dist/theme-editor.d.ts +521 -23
  39. package/dist/theme-editor.js +10 -10
  40. package/dist/theme-reference.cjs +1 -1
  41. package/dist/theme-reference.d.cts +104 -3
  42. package/dist/theme-reference.d.ts +104 -3
  43. package/dist/theme-reference.js +1 -1
  44. package/dist/widget.css +1 -1
  45. package/package.json +1 -1
  46. package/src/ask-user-question-tool.ts +10 -7
  47. package/src/components/artifact-pane.test.ts +27 -0
  48. package/src/components/artifact-pane.ts +7 -3
  49. package/src/components/artifact-preview.ts +1 -1
  50. package/src/components/composer-parts.test.ts +103 -4
  51. package/src/components/composer-parts.ts +53 -15
  52. package/src/components/header-builder.ts +27 -2
  53. package/src/components/header-layouts.test.ts +56 -0
  54. package/src/components/header-layouts.ts +29 -9
  55. package/src/components/launcher-surface.test.ts +403 -0
  56. package/src/components/launcher.ts +297 -2
  57. package/src/components/messages.ts +5 -2
  58. package/src/components/panel.ts +92 -71
  59. package/src/components/pill-composer-builder.test.ts +18 -0
  60. package/src/components/suggestions.test.ts +273 -0
  61. package/src/components/suggestions.ts +528 -86
  62. package/src/components/welcome.test.ts +121 -0
  63. package/src/components/welcome.ts +305 -0
  64. package/src/components/widget-view.test.ts +5 -3
  65. package/src/components/widget-view.ts +18 -7
  66. package/src/defaults.ts +7 -5
  67. package/src/generated/runtype-openapi-contract.ts +17 -0
  68. package/src/index-core.ts +29 -2
  69. package/src/launcher-global.ts +11 -8
  70. package/src/plugins/types.ts +136 -3
  71. package/src/presets.ts +3 -3
  72. package/src/session.ts +10 -8
  73. package/src/styles/widget-styles.test.ts +382 -0
  74. package/src/styles/widget.css +778 -4
  75. package/src/suggest-replies-tool.test.ts +233 -2
  76. package/src/suggest-replies-tool.ts +115 -39
  77. package/src/testing/mock-stream.test.ts +19 -0
  78. package/src/testing/mock-stream.ts +20 -0
  79. package/src/theme-editor/preview-utils.test.ts +34 -0
  80. package/src/theme-editor/preview-utils.ts +77 -2
  81. package/src/theme-editor/sections.test.ts +33 -0
  82. package/src/theme-editor/sections.ts +83 -6
  83. package/src/theme-reference.ts +34 -5
  84. package/src/types/theme.ts +72 -3
  85. package/src/types.ts +312 -18
  86. package/src/ui.body-top-slot.test.ts +95 -0
  87. package/src/ui.content-width.test.ts +91 -0
  88. package/src/ui.detached-panel.test.ts +1 -1
  89. package/src/ui.docked.test.ts +7 -4
  90. package/src/ui.launcher-teaser.test.ts +162 -0
  91. package/src/ui.rebuild-composer.test.ts +491 -0
  92. package/src/ui.render-welcome.test.ts +505 -0
  93. package/src/ui.scroll-additive.test.ts +91 -21
  94. package/src/ui.scroll.test.ts +302 -14
  95. package/src/ui.suggest-replies.test.ts +1112 -1
  96. package/src/ui.ts +1008 -149
  97. package/src/ui.welcome.test.ts +504 -0
  98. package/src/utils/artifact-file.test.ts +1 -1
  99. package/src/utils/artifact-gate.test.ts +27 -0
  100. package/src/utils/artifact-gate.ts +9 -0
  101. package/src/utils/attachment-manager.test.ts +37 -0
  102. package/src/utils/attachment-manager.ts +15 -0
  103. package/src/utils/auto-follow.test.ts +23 -0
  104. package/src/utils/auto-follow.ts +11 -1
  105. package/src/utils/code-generators.test.ts +40 -0
  106. package/src/utils/code-generators.ts +22 -0
  107. package/src/utils/config-merge.test.ts +24 -0
  108. package/src/utils/config-merge.ts +2 -0
  109. package/src/utils/content-width.test.ts +37 -0
  110. package/src/utils/content-width.ts +17 -0
  111. package/src/utils/icons.test.ts +26 -0
  112. package/src/utils/icons.ts +8 -0
  113. package/src/utils/plugin-storage.test.ts +94 -0
  114. package/src/utils/plugin-storage.ts +98 -0
  115. package/src/utils/theme.test.ts +216 -0
  116. package/src/utils/tokens.ts +113 -3
  117. package/src/welcome.test.ts +213 -0
  118. package/src/welcome.ts +130 -0
  119. package/dist/chunk-MMUPR2JW.js +0 -1
@@ -1,3 +1,5 @@
1
+ import type { ResolvedWelcomeConfig } from "../welcome";
2
+ import type { AgentWidgetPluginStorage } from "../utils/plugin-storage";
1
3
  import {
2
4
  AgentWidgetMessage,
3
5
  AgentWidgetConfig,
@@ -8,9 +10,74 @@ import {
8
10
  EventStreamViewRenderContext,
9
11
  EventStreamRowRenderContext,
10
12
  EventStreamToolbarRenderContext,
11
- EventStreamPayloadRenderContext
13
+ EventStreamPayloadRenderContext,
14
+ AgentWidgetSuggestion,
15
+ AgentWidgetResolvedSuggestion,
16
+ AgentWidgetSuggestionSource,
17
+ AgentWidgetSuggestionSurface,
18
+ AgentWidgetSuggestionVariant,
19
+ AgentWidgetWelcomeVariant
12
20
  } from "../types";
13
21
 
22
+ export type AgentWidgetTransformSuggestionsContext = {
23
+ /**
24
+ * Fresh copy of the suggestions entering this plugin, already normalized:
25
+ * shorthand strings expanded and the effective send/fill behavior resolved.
26
+ */
27
+ suggestions: AgentWidgetResolvedSuggestion[];
28
+ surface: AgentWidgetSuggestionSurface;
29
+ source: AgentWidgetSuggestionSource;
30
+ config: AgentWidgetConfig;
31
+ };
32
+
33
+ export type AgentWidgetRenderSuggestionContext = {
34
+ /** Fully normalized suggestion, including the effective send/fill behavior. */
35
+ suggestion: AgentWidgetResolvedSuggestion;
36
+ index: number;
37
+ surface: AgentWidgetSuggestionSurface;
38
+ source: AgentWidgetSuggestionSource;
39
+ variant: AgentWidgetSuggestionVariant;
40
+ streaming: boolean;
41
+ config: AgentWidgetConfig;
42
+ /** Build Persona's standard chip, card, or list item. */
43
+ defaultRenderer: () => HTMLElement;
44
+ /**
45
+ * Run the normal selection pipeline: DOM event, plugin selection hooks, then
46
+ * send or fill. Custom renderers should call this instead of sending directly.
47
+ */
48
+ select: () => void;
49
+ };
50
+
51
+ export type AgentWidgetRenderWelcomeContext = {
52
+ /** Alias-resolved welcome config; never the raw `copy.welcome*` shape. */
53
+ config: ResolvedWelcomeConfig;
54
+ variant: AgentWidgetWelcomeVariant;
55
+ /** Derived visibility for this render; governs the default renderer only. */
56
+ visible: boolean;
57
+ /** The core-owned welcome card, live-updated by `controller.update()`. */
58
+ defaultRenderer: () => HTMLElement;
59
+ sendMessage: (text: string) => void;
60
+ /** Re-run welcome arbitration: cleanups run, previous content is removed. */
61
+ requestRender: () => void;
62
+ /**
63
+ * Persona's starter renderer, wired through the full select pipeline:
64
+ * `onSuggestionSelect`, cancelable `persona:suggestion:*` events, send/fill.
65
+ */
66
+ renderStarter: (suggestion: AgentWidgetSuggestion) => HTMLElement;
67
+ /** Sync store namespaced by `persistState.keyPrefix` plus the plugin id. */
68
+ storage: AgentWidgetPluginStorage;
69
+ /** Teardown for this render; runs before the next one and on destroy. */
70
+ onCleanup: (fn: () => void) => void;
71
+ };
72
+
73
+ export type AgentWidgetSuggestionSelectContext = {
74
+ suggestion: AgentWidgetResolvedSuggestion;
75
+ surface: AgentWidgetSuggestionSurface;
76
+ source: AgentWidgetSuggestionSource;
77
+ variant: AgentWidgetSuggestionVariant;
78
+ config: AgentWidgetConfig;
79
+ };
80
+
14
81
  /**
15
82
  * Plugin interface for customizing widget components
16
83
  */
@@ -25,6 +92,34 @@ export interface AgentWidgetPlugin {
25
92
  */
26
93
  priority?: number;
27
94
 
95
+ /**
96
+ * Transform, filter, enrich, or reorder suggestions. Hooks receive normalized
97
+ * items and compose in plugin priority order; the return value may use the
98
+ * loose shape (string shorthand included) and is re-normalized before the next
99
+ * hook. A per-item `behavior` set here survives. `maxItems` caps the list
100
+ * after the full chain. Return an empty array to hide the surface.
101
+ */
102
+ transformSuggestions?: (
103
+ context: AgentWidgetTransformSuggestionsContext
104
+ ) => AgentWidgetSuggestion[];
105
+
106
+ /**
107
+ * Render one starter or follow-up suggestion. Return null to use Persona's
108
+ * default chip/card/list renderer. Call `select()` from custom interaction
109
+ * controls to preserve events, selection hooks, and send/fill behavior.
110
+ */
111
+ renderSuggestion?: (
112
+ context: AgentWidgetRenderSuggestionContext
113
+ ) => HTMLElement | null;
114
+
115
+ /**
116
+ * Observe or intercept a suggestion selection. Return false to cancel the
117
+ * built-in send/fill action. Hooks run in plugin priority order.
118
+ */
119
+ onSuggestionSelect?: (
120
+ context: AgentWidgetSuggestionSelectContext
121
+ ) => void | boolean;
122
+
28
123
  /**
29
124
  * Custom renderer for message bubbles
30
125
  * Return null to use default renderer
@@ -45,6 +140,37 @@ export interface AgentWidgetPlugin {
45
140
  onToggle: () => void;
46
141
  }) => HTMLElement | null;
47
142
 
143
+ /**
144
+ * Custom renderer for the welcome surface (card, hero, greeting stack).
145
+ * First plugin returning an element wins; null falls through to the default,
146
+ * same contract as `renderSuggestion`.
147
+ *
148
+ * The core owns the welcome host: it stays mounted for the panel's lifetime
149
+ * and content is swapped inside it. A plugin element renders regardless of
150
+ * `ctx.visible` (derived visibility governs the default renderer only) and
151
+ * the host overlays the transcript while that element is active, so a home
152
+ * screen can return over an existing conversation. Return null again to
153
+ * clear the overlay.
154
+ *
155
+ * The ctx carries no controller: plugins are host-instantiated, so close a
156
+ * factory over the init handle (`plugin.attach(controller)`).
157
+ *
158
+ * @example
159
+ * ```typescript
160
+ * renderWelcome: ({ config, renderStarter, requestRender, onCleanup }) => {
161
+ * const root = document.createElement("div");
162
+ * root.textContent = config.title;
163
+ * root.appendChild(renderStarter({ label: "Track my order" }));
164
+ * const timer = setInterval(requestRender, 60_000);
165
+ * onCleanup(() => clearInterval(timer));
166
+ * return root;
167
+ * }
168
+ * ```
169
+ */
170
+ renderWelcome?: (
171
+ context: AgentWidgetRenderWelcomeContext
172
+ ) => HTMLElement | null;
173
+
48
174
  /**
49
175
  * Custom renderer for panel header
50
176
  * Return null to use default renderer
@@ -87,6 +213,15 @@ export interface AgentWidgetPlugin {
87
213
  * Omitted when `config.voiceRecognition.enabled` is not true.
88
214
  */
89
215
  onVoiceToggle?: () => void;
216
+ /**
217
+ * Re-run composer arbitration and swap the footer in place. The hook runs
218
+ * once at panel construction, so this is how a composer plugin unlocks
219
+ * (returns null) or re-renders later. Pending attachments, the mention
220
+ * affordances, and the composer listener registry are re-wired by the core.
221
+ */
222
+ requestRender: () => void;
223
+ /** Same sync store as the `renderWelcome` ctx, namespaced per plugin id. */
224
+ storage: AgentWidgetPluginStorage;
90
225
  }) => HTMLElement | null;
91
226
 
92
227
  /**
@@ -298,5 +433,3 @@ export interface AgentWidgetPlugin {
298
433
 
299
434
 
300
435
 
301
-
302
-
package/src/presets.ts CHANGED
@@ -75,9 +75,9 @@ export const PRESET_SHOP: WidgetPreset = {
75
75
  sendButtonLabel: "Send",
76
76
  },
77
77
  suggestionChips: [
78
- "What can you help me with?",
79
- "Tell me about your features",
80
- "How does this work?",
78
+ "Show me what you can help with",
79
+ "Walk me through getting started",
80
+ "Answer a question about this page",
81
81
  ],
82
82
  },
83
83
  };
package/src/session.ts CHANGED
@@ -2,6 +2,7 @@ import { AgentWidgetClient, type SSEEventCallback } from "./client";
2
2
  import { isWebMcpToolName } from "./webmcp-bridge";
3
3
  import {
4
4
  SUGGEST_REPLIES_TOOL_NAME,
5
+ resolveFollowUpsFeature,
5
6
  suggestRepliesToolResult,
6
7
  } from "./suggest-replies-tool";
7
8
  import {
@@ -77,10 +78,11 @@ export type AgentWidgetSessionStatus =
77
78
  * request. When NONE of these change, `updateConfig` can refresh the live
78
79
  * client in place (preserving the WebMCP bridge and any in-flight stream/resume)
79
80
  * instead of swapping in a fresh client and tearing down WebMCP state. Fields
80
- * outside this list (theme, copy, layout, suggestionChips, iterationDisplay,
81
- * postprocessMessage, feature display toggles, …) are display-only and safe to
82
- * apply mid-turn: which is what a self-styling widget needs when a `webmcp:*`
83
- * theme tool re-renders the widget while the agent's turn is still streaming.
81
+ * outside this list (theme, copy, layout, suggestions, suggestionChips,
82
+ * iterationDisplay, postprocessMessage, feature display toggles, …) are
83
+ * display-only and safe to apply mid-turn: which is what a self-styling widget
84
+ * needs when a `webmcp:*` theme tool re-renders the widget while the agent's
85
+ * turn is still streaming.
84
86
  *
85
87
  * Compared by identity (`!==`): primitives by value, functions/objects by
86
88
  * reference. A consumer that rebuilds these objects on every render simply
@@ -2983,15 +2985,15 @@ export class AgentWidgetSession {
2983
2985
  // indefinitely: `resolveWebMcpToolCall` translates the missing-bridge
2984
2986
  // case into an isError result that resumes the flow cleanly.
2985
2987
  // `suggest_replies`, by contrast, is gated on its feature flag: when
2986
- // `features.suggestReplies.enabled === false` the widget neither
2987
- // renders chips nor resumes: the same parked-execution posture as a
2988
- // server-declared ask_user_question with its sheet disabled.
2988
+ // the resolved `enabled` is false the widget neither renders chips nor
2989
+ // resumes: the same parked-execution posture as a server-declared
2990
+ // ask_user_question with its sheet disabled.
2989
2991
  const tc = event.message.toolCall;
2990
2992
  const autoResolvable =
2991
2993
  !!tc?.name &&
2992
2994
  (isWebMcpToolName(tc.name) ||
2993
2995
  (tc.name === SUGGEST_REPLIES_TOOL_NAME &&
2994
- this.config.features?.suggestReplies?.enabled !== false));
2996
+ resolveFollowUpsFeature(this.config).enabled));
2995
2997
  if (
2996
2998
  event.message.agentMetadata?.awaitingLocalTool === true &&
2997
2999
  autoResolvable
@@ -0,0 +1,382 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ const widgetCss = readFileSync(
6
+ new URL("./widget.css", import.meta.url),
7
+ "utf8",
8
+ );
9
+
10
+ describe("suggestion interaction styles", () => {
11
+ it("keeps suggestion surfaces from collapsing inside constrained transcripts", () => {
12
+ const selector = ".persona-suggestions {";
13
+ const start = widgetCss.indexOf(selector);
14
+ const end = widgetCss.indexOf("\n}", start);
15
+ const surfaceRule = widgetCss.slice(start, end);
16
+
17
+ expect(start).toBeGreaterThan(-1);
18
+ expect(surfaceRule).toContain("flex-shrink: 0");
19
+ });
20
+
21
+ it("keeps the primary emphasis a quiet accent rather than a solid fill", () => {
22
+ const selector = '.persona-suggestion[data-emphasis="primary"] {';
23
+ const start = widgetCss.indexOf(selector);
24
+ const rule = widgetCss.slice(start, widgetCss.indexOf("\n}", start));
25
+
26
+ expect(start).toBeGreaterThan(-1);
27
+ expect(rule).toContain("border-color: var(--persona-primary)");
28
+ // Plain declaration first so engines without color-mix stay transparent.
29
+ expect(rule).toContain("background: transparent");
30
+ expect(rule).toContain(
31
+ "background: color-mix(in srgb, var(--persona-primary) 6%, transparent)",
32
+ );
33
+ expect(rule).not.toContain("--persona-text-inverse");
34
+ });
35
+
36
+ it("strengthens the primary wash on hover instead of filling it", () => {
37
+ const selector =
38
+ '.persona-suggestion[data-emphasis="primary"]:hover {';
39
+ const start = widgetCss.indexOf(selector);
40
+ const hoverRule = widgetCss.slice(start, widgetCss.indexOf("\n }", start));
41
+
42
+ expect(start).toBeGreaterThan(-1);
43
+ expect(hoverRule).toContain("border-color: var(--persona-primary)");
44
+ expect(hoverRule).toContain(
45
+ "color-mix(in srgb, var(--persona-primary) 12%, transparent)",
46
+ );
47
+ expect(hoverRule).not.toContain("filter: brightness");
48
+ });
49
+
50
+ it("rests cards flat and reserves the shadow plus lift for hover", () => {
51
+ const restStart = widgetCss.indexOf(".persona-suggestion--card {");
52
+ const restRule = widgetCss.slice(
53
+ restStart,
54
+ widgetCss.indexOf("\n}", restStart),
55
+ );
56
+
57
+ expect(restStart).toBeGreaterThan(-1);
58
+ expect(restRule).toContain(
59
+ "box-shadow: var(--persona-components-suggestion-card-shadow, none)",
60
+ );
61
+
62
+ const hoverStart = widgetCss.indexOf(".persona-suggestion--card:hover {");
63
+ const hoverRule = widgetCss.slice(
64
+ hoverStart,
65
+ widgetCss.indexOf("\n }", hoverStart),
66
+ );
67
+
68
+ expect(hoverStart).toBeGreaterThan(-1);
69
+ expect(hoverRule).toContain("box-shadow: var(--persona-shadow-sm)");
70
+ expect(hoverRule).toContain("transform: translateY(-1px)");
71
+ });
72
+
73
+ it("reveals the suggestion arrow only on hover and keyboard focus", () => {
74
+ // Searched past `__copy`: the bare selector also appears in the shared
75
+ // `flex: 0 0 auto` group above it and the coarse-pointer override below.
76
+ const restStart = widgetCss.indexOf(
77
+ ".persona-suggestion__arrow {",
78
+ widgetCss.indexOf(".persona-suggestion__copy {"),
79
+ );
80
+ const restRule = widgetCss.slice(
81
+ restStart,
82
+ widgetCss.indexOf("\n}", restStart),
83
+ );
84
+
85
+ expect(restStart).toBeGreaterThan(-1);
86
+ expect(restRule).toContain("opacity: 0;");
87
+ expect(restRule).toContain("transition: opacity");
88
+ expect(widgetCss).toContain(
89
+ ".persona-suggestion:focus-visible .persona-suggestion__arrow",
90
+ );
91
+ expect(widgetCss).toContain(
92
+ ".persona-suggestion:hover .persona-suggestion__arrow",
93
+ );
94
+ });
95
+
96
+ it("caps the starter card grid so wide panels get two columns", () => {
97
+ expect(widgetCss).toContain(
98
+ "grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr))",
99
+ );
100
+ const selector =
101
+ '.persona-suggestions[data-variant="card"][data-persona-suggestion-surface="starter"] {';
102
+ const start = widgetCss.indexOf(selector);
103
+ const rule = widgetCss.slice(start, widgetCss.indexOf("\n}", start));
104
+
105
+ expect(start).toBeGreaterThan(-1);
106
+ expect(rule).toContain("max-width: 600px");
107
+ });
108
+
109
+ it("centers label-only items and top-aligns only description cards", () => {
110
+ const cardStart = widgetCss.indexOf(".persona-suggestion--card {");
111
+ const cardRule = widgetCss.slice(
112
+ cardStart,
113
+ widgetCss.indexOf("\n}", cardStart),
114
+ );
115
+ expect(cardRule).toContain("align-items: center");
116
+
117
+ const listStart = widgetCss.indexOf(".persona-suggestion--list {");
118
+ const listRule = widgetCss.slice(
119
+ listStart,
120
+ widgetCss.indexOf("\n}", listStart),
121
+ );
122
+ expect(listRule).toContain("align-items: center");
123
+
124
+ // Only content that actually wraps to two lines earns the top alignment.
125
+ const hasStart = widgetCss.indexOf(
126
+ ".persona-suggestion--card:has(.persona-suggestion__description)",
127
+ );
128
+ const hasRule = widgetCss.slice(
129
+ hasStart,
130
+ widgetCss.indexOf("\n}", hasStart),
131
+ );
132
+ expect(hasStart).toBeGreaterThan(-1);
133
+ expect(hasRule).toContain(
134
+ ".persona-suggestion--list:has(.persona-suggestion__description)",
135
+ );
136
+ expect(hasRule).toContain("align-items: flex-start");
137
+ });
138
+
139
+ it("masks only the scrollable chip edges that still contain content", () => {
140
+ expect(widgetCss).toContain(
141
+ '[data-overflow="scroll"][data-scroll-right]:not(',
142
+ );
143
+ expect(widgetCss).toContain(
144
+ '[data-overflow="scroll"][data-scroll-left]:not(',
145
+ );
146
+ expect(widgetCss).toContain(
147
+ '[data-overflow="scroll"][data-scroll-left][data-scroll-right]',
148
+ );
149
+ expect(widgetCss).toContain(
150
+ "--persona-suggestion-scroll-fade-size: 32px",
151
+ );
152
+ });
153
+
154
+ it("centers the chip row on the composer surface only", () => {
155
+ const selector =
156
+ '[data-persona-composer-suggestions].persona-suggestions[data-variant="chip"] {';
157
+ const start = widgetCss.indexOf(selector);
158
+ const rule = widgetCss.slice(start, widgetCss.indexOf("\n}", start));
159
+
160
+ expect(start).toBeGreaterThan(-1);
161
+ // fit-content + auto margins center a single row while wrapped rows keep
162
+ // a shared left edge; per-row justify-content would rag them.
163
+ expect(rule).toContain("width: fit-content");
164
+ expect(rule).toContain("max-width: 100%");
165
+ expect(rule).toContain("margin-inline: auto");
166
+ expect(rule).not.toContain("justify-content");
167
+
168
+ // Transcript follow-ups and welcome rows keep the default left alignment.
169
+ const sharedStart = widgetCss.indexOf(
170
+ '.persona-suggestions[data-variant="chip"] {',
171
+ );
172
+ const sharedRule = widgetCss.slice(
173
+ sharedStart,
174
+ widgetCss.indexOf("\n}", sharedStart),
175
+ );
176
+ expect(sharedStart).toBeGreaterThan(-1);
177
+ expect(sharedRule).not.toContain("justify-content");
178
+ });
179
+
180
+ it("only pulls follow-ups up against the roomy welcome-visible body gap", () => {
181
+ // Unscoped, the -12px offset would zero out the tightened 12px gap that
182
+ // applies when the welcome host is hidden, gluing follow-ups to the answer.
183
+ expect(widgetCss).toContain(
184
+ '.persona-gap-6 > [data-persona-suggestions="follow-up"] {',
185
+ );
186
+ expect(widgetCss).not.toMatch(
187
+ /\n\[data-persona-suggestions="follow-up"\] \{/,
188
+ );
189
+ });
190
+ });
191
+
192
+ describe("composer spacing styles", () => {
193
+ it("themes the composer form padding and gap with utility-matching fallbacks", () => {
194
+ const selector =
195
+ "[data-persona-root] .persona-widget-composer:not(.persona-pill-composer) {";
196
+ const start = widgetCss.indexOf(selector);
197
+ const rule = widgetCss.slice(start, widgetCss.indexOf("\n}", start));
198
+
199
+ expect(start).toBeGreaterThan(-1);
200
+ // Fallbacks reproduce `persona-px-4 persona-py-3` and `persona-gap-2`.
201
+ expect(rule).toContain(
202
+ "padding: var(--persona-composer-padding, 0.75rem 1rem)",
203
+ );
204
+ expect(rule).toContain("gap: var(--persona-composer-gap, 0.5rem)");
205
+ });
206
+
207
+ it("themes the composer textarea type with text-sm fallbacks", () => {
208
+ const selector = "[data-persona-root] .persona-composer-textarea {";
209
+ const start = widgetCss.indexOf(selector);
210
+ const rule = widgetCss.slice(start, widgetCss.indexOf("\n}", start));
211
+
212
+ expect(start).toBeGreaterThan(-1);
213
+ expect(rule).toContain(
214
+ "font-size: var(--persona-composer-font-size, 0.875rem)",
215
+ );
216
+ expect(rule).toContain(
217
+ "line-height: var(--persona-composer-line-height, 1.25rem)",
218
+ );
219
+ });
220
+
221
+ it("leaves the pill composer on its own single-row geometry", () => {
222
+ const start = widgetCss.indexOf(".persona-pill-composer {");
223
+ const rule = widgetCss.slice(start, widgetCss.indexOf("\n}", start));
224
+
225
+ expect(start).toBeGreaterThan(-1);
226
+ expect(rule).toContain("padding: 6px 14px");
227
+ expect(rule).toContain("gap: 8px");
228
+ });
229
+ });
230
+
231
+ describe("transcript layout styles", () => {
232
+ it("drops the empty messages wrapper from the flex flow", () => {
233
+ // A zero-height flex child still triggers the body's gap, so an empty
234
+ // transcript added phantom scrollable space below the welcome (and a
235
+ // scroll-to-bottom arrow with nothing to scroll to).
236
+ expect(widgetCss).toContain(
237
+ ".persona-widget-messages:empty {\n display: none;\n}",
238
+ );
239
+ });
240
+ });
241
+
242
+ describe("launcher teaser styles", () => {
243
+ it("keeps the [hidden] dismiss control hidden despite its own display: grid", () => {
244
+ // dismissible: false sets the hidden property; without this companion
245
+ // rule the block's display: grid outranks the UA [hidden] stylesheet.
246
+ expect(widgetCss).toContain(
247
+ ".persona-launcher-teaser-dismiss[hidden] {\n display: none;\n}",
248
+ );
249
+ });
250
+ });
251
+
252
+ describe("plugin welcome styles", () => {
253
+ it("hides the default welcome content while a plugin element owns the host", () => {
254
+ const selector =
255
+ '.persona-welcome[data-persona-welcome-content="plugin"]\n > :not([data-persona-welcome-plugin]) {';
256
+ const start = widgetCss.indexOf(selector);
257
+ const rule = widgetCss.slice(start, widgetCss.indexOf("\n}", start));
258
+
259
+ expect(start).toBeGreaterThan(-1);
260
+ expect(rule).toContain("display: none !important");
261
+ });
262
+
263
+ it("centers the welcome column at wide widths but never the overlay", () => {
264
+ const start = widgetCss.indexOf(
265
+ ".persona-welcome:not([data-persona-welcome-overlay]) {",
266
+ );
267
+ const rule = widgetCss.slice(start, widgetCss.indexOf("\n}", start));
268
+
269
+ expect(start).toBeGreaterThan(-1);
270
+ // The card variant shares the transcript column so its left-aligned
271
+ // text lines up with messages and composer.
272
+ expect(rule).toContain("max-width: var(--persona-content-max-width, 768px)");
273
+ expect(rule).toContain("margin-inline: auto");
274
+ // The var is published on the root so plugin content can consume it.
275
+ expect(widgetCss).toContain("--persona-welcome-max-width: 640px");
276
+ });
277
+
278
+ it("keeps the narrower centered column for the hero variant only", () => {
279
+ const start = widgetCss.indexOf(
280
+ '.persona-welcome[data-persona-welcome-variant="hero"]:not(',
281
+ );
282
+ const rule = widgetCss.slice(start, widgetCss.indexOf("\n}", start));
283
+
284
+ expect(start).toBeGreaterThan(-1);
285
+ expect(rule).toContain("max-width: var(--persona-welcome-max-width, 640px)");
286
+ });
287
+
288
+ it("aligns the greeting bubble to the transcript column", () => {
289
+ const start = widgetCss.indexOf(".persona-welcome-greeting {");
290
+ const rule = widgetCss.slice(start, widgetCss.indexOf("\n}", start));
291
+
292
+ expect(start).toBeGreaterThan(-1);
293
+ expect(rule).toContain("max-width: var(--persona-content-max-width, 768px)");
294
+ expect(rule).toContain("margin-inline: auto");
295
+ });
296
+
297
+ it("pads the welcome host from the intro-card alias, full padding on overlays", () => {
298
+ // Flat default resolves the alias to `1.5rem 0` (tokens.ts), aligning
299
+ // the text with the content column; overlays are opaque surfaces and
300
+ // keep a fixed symmetric inset.
301
+ const start = widgetCss.indexOf(".persona-welcome {");
302
+ const rule = widgetCss.slice(start, widgetCss.indexOf("\n}", start));
303
+
304
+ expect(start).toBeGreaterThan(-1);
305
+ expect(rule).toContain("padding: var(--persona-intro-card-padding, 1.5rem)");
306
+
307
+ const overlayStart = widgetCss.indexOf(
308
+ ".persona-welcome[data-persona-welcome-overlay] {",
309
+ );
310
+ const overlayRule = widgetCss.slice(
311
+ overlayStart,
312
+ widgetCss.indexOf("\n}", overlayStart),
313
+ );
314
+ expect(overlayRule).toContain("padding: 1.5rem");
315
+ });
316
+
317
+ it("overlays the messages area while plugin content is active", () => {
318
+ const start = widgetCss.indexOf(
319
+ ".persona-welcome[data-persona-welcome-overlay] {",
320
+ );
321
+ const rule = widgetCss.slice(start, widgetCss.indexOf("\n}", start));
322
+
323
+ expect(start).toBeGreaterThan(-1);
324
+ expect(rule).toContain("position: absolute");
325
+ expect(rule).toContain("inset: 0");
326
+ // !important: only way to beat the host's inline intro-card background
327
+ // (default transparent), which otherwise lets the transcript bleed
328
+ // through the overlay.
329
+ expect(rule).toMatch(/background:[\s\S]*!important/);
330
+ // Two classes so it outranks the body's own overflow utility.
331
+ expect(widgetCss).toContain(
332
+ ".persona-widget-body.persona-welcome-overlay-active {\n overflow: hidden;\n}",
333
+ );
334
+ });
335
+ });
336
+
337
+ describe("scrollbar policy styles", () => {
338
+ it("themes every scroller from the shared tokens", () => {
339
+ // scrollbar-color inherits from the root; scrollbar-width does not, so
340
+ // the width needs the descendant star.
341
+ expect(widgetCss).toMatch(
342
+ /\[data-persona-root\] \{\n {2}scrollbar-color: var\(\n\s+--persona-scrollbar-thumb,/,
343
+ );
344
+ expect(widgetCss).toContain("var(--persona-scrollbar-track, transparent)");
345
+ expect(widgetCss).toContain(
346
+ "[data-persona-root],\n[data-persona-root] * {\n scrollbar-width: thin;\n}",
347
+ );
348
+ // The artifact tab strip keeps its own token as an alias layered on the
349
+ // shared thumb token.
350
+ expect(widgetCss).toContain(
351
+ "--persona-artifact-tab-list-scrollbar,\n var(--persona-scrollbar-thumb, var(--persona-border, #e5e7eb))",
352
+ );
353
+ });
354
+
355
+ it("hides the transcript bar at rest under the on-scroll policy without reflow", () => {
356
+ // Rest state keys off the ui.ts-owned attributes; the reveal attribute
357
+ // must appear as a :not() so flipping it shows the bar again.
358
+ expect(widgetCss).toContain(
359
+ '[data-persona-scrollbar="on-scroll"]\n .persona-widget-body:not([data-persona-scrollbar-visible]),\n[data-persona-scrollbar="hidden"] .persona-widget-body {\n scrollbar-width: none;\n}',
360
+ );
361
+ // Old-Safari fallback: webkit hiding agrees with the standard property.
362
+ expect(widgetCss).toContain(
363
+ '[data-persona-scrollbar="hidden"] .persona-widget-body::-webkit-scrollbar {\n display: none;\n}',
364
+ );
365
+ // Classic-scrollbar platforms must not reflow the transcript on toggle.
366
+ expect(widgetCss).toContain(
367
+ '[data-persona-scrollbar="on-scroll"] .persona-widget-body {\n scrollbar-gutter: stable;\n}',
368
+ );
369
+ });
370
+
371
+ it("hover-reveals inner scroller bars under the on-scroll policy", () => {
372
+ expect(widgetCss).toContain(
373
+ '[data-persona-scrollbar="on-scroll"] .persona-artifact-list:not(:hover)',
374
+ );
375
+ expect(widgetCss).toContain(
376
+ '[data-persona-scrollbar="on-scroll"]\n .persona-suggestions[data-overflow="scroll"]:not(:hover)',
377
+ );
378
+ expect(widgetCss).toContain(
379
+ '[data-persona-scrollbar="hidden"] .persona-artifact-list,',
380
+ );
381
+ });
382
+ });