@runtypelabs/persona 3.29.0 → 3.30.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 (63) hide show
  1. package/README.md +55 -0
  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-CxvHw0X6.d.cts → types-B_Qazlm4.d.cts} +6 -0
  5. package/dist/animations/{types-CxvHw0X6.d.ts → types-B_Qazlm4.d.ts} +6 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +6 -6
  9. package/dist/codegen.js +4 -4
  10. package/dist/index.cjs +47 -47
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +127 -7
  13. package/dist/index.d.ts +127 -7
  14. package/dist/index.global.js +60 -60
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +47 -47
  17. package/dist/index.js.map +1 -1
  18. package/dist/launcher.global.js +2 -2
  19. package/dist/launcher.global.js.map +1 -1
  20. package/dist/plugin-kit.cjs +1 -0
  21. package/dist/plugin-kit.d.cts +98 -0
  22. package/dist/plugin-kit.d.ts +98 -0
  23. package/dist/plugin-kit.js +1 -0
  24. package/dist/smart-dom-reader.d.cts +113 -4
  25. package/dist/smart-dom-reader.d.ts +113 -4
  26. package/dist/theme-editor.cjs +38 -38
  27. package/dist/theme-editor.d.cts +117 -6
  28. package/dist/theme-editor.d.ts +117 -6
  29. package/dist/theme-editor.js +38 -38
  30. package/dist/theme-reference.cjs +1 -1
  31. package/dist/theme-reference.d.cts +2 -0
  32. package/dist/theme-reference.d.ts +2 -0
  33. package/dist/theme-reference.js +1 -1
  34. package/package.json +8 -2
  35. package/src/client.test.ts +40 -0
  36. package/src/client.ts +18 -4
  37. package/src/components/approval-bubble.test.ts +268 -0
  38. package/src/components/approval-bubble.ts +170 -20
  39. package/src/components/launcher.ts +6 -3
  40. package/src/components/tool-bubble.test.ts +39 -0
  41. package/src/components/tool-bubble.ts +4 -0
  42. package/src/generated/runtype-openapi-contract.ts +12 -0
  43. package/src/index-core.ts +1 -0
  44. package/src/plugin-kit.test.ts +230 -0
  45. package/src/plugin-kit.ts +294 -0
  46. package/src/plugins/types.ts +49 -2
  47. package/src/session.test.ts +99 -0
  48. package/src/session.ts +204 -32
  49. package/src/session.webmcp.test.ts +326 -6
  50. package/src/theme-editor/preview-utils.test.ts +10 -0
  51. package/src/theme-editor/preview-utils.ts +29 -1
  52. package/src/theme-editor/sections.test.ts +17 -0
  53. package/src/theme-editor/sections.ts +31 -0
  54. package/src/theme-reference.ts +1 -1
  55. package/src/types/theme.ts +2 -0
  56. package/src/types.ts +59 -2
  57. package/src/ui.approval-plugin.test.ts +204 -0
  58. package/src/ui.ts +149 -16
  59. package/src/utils/theme.test.ts +6 -2
  60. package/src/utils/theme.ts +0 -8
  61. package/src/utils/tokens.ts +6 -1
  62. package/src/webmcp-bridge.test.ts +66 -0
  63. package/src/webmcp-bridge.ts +49 -0
@@ -2,6 +2,86 @@ import { createElement } from "../utils/dom";
2
2
  import { AgentWidgetMessage, AgentWidgetConfig } from "../types";
3
3
  import { formatUnknownValue } from "../utils/formatting";
4
4
  import { renderLucideIcon } from "../utils/icons";
5
+ import { WEBMCP_TOOL_PREFIX, getWebMcpToolDisplayTitle } from "../webmcp-bridge";
6
+
7
+ /**
8
+ * Per-message expanded/collapsed state for the technical-details section.
9
+ * Absent means "use the config default" (`approval.detailsDisplay`). Lives at
10
+ * module scope so the choice survives idiomorph re-renders, mirroring
11
+ * `toolExpansionState` in tool-bubble.ts.
12
+ */
13
+ export const approvalDetailsExpansionState = new Map<string, boolean>();
14
+
15
+ /**
16
+ * Turn a wire tool name into a user-facing label: strips the `webmcp:`
17
+ * prefix and splits snake_case / kebab-case / camelCase into a sentence
18
+ * (`add_to_cart` → "Add to cart"). Falls back to the input when nothing
19
+ * word-like remains.
20
+ */
21
+ export const humanizeToolName = (toolName: string): string => {
22
+ const bare = toolName.startsWith(WEBMCP_TOOL_PREFIX)
23
+ ? toolName.slice(WEBMCP_TOOL_PREFIX.length)
24
+ : toolName;
25
+ const words = bare
26
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
27
+ .split(/[_\-\s.]+/)
28
+ .filter(Boolean);
29
+ if (words.length === 0) return toolName;
30
+ const sentence = words.join(" ").toLowerCase();
31
+ return sentence.charAt(0).toUpperCase() + sentence.slice(1);
32
+ };
33
+
34
+ const resolveApprovalConfig = (config?: AgentWidgetConfig) =>
35
+ config?.approval !== false ? config?.approval : undefined;
36
+
37
+ const isDetailsExpanded = (
38
+ messageId: string,
39
+ config?: AgentWidgetConfig
40
+ ): boolean => {
41
+ const detailsMode = resolveApprovalConfig(config)?.detailsDisplay ?? "collapsed";
42
+ return approvalDetailsExpansionState.get(messageId) ?? detailsMode === "expanded";
43
+ };
44
+
45
+ const applyDetailsToggleState = (
46
+ toggle: HTMLElement,
47
+ expanded: boolean,
48
+ config?: AgentWidgetConfig
49
+ ): void => {
50
+ const approvalConfig = resolveApprovalConfig(config);
51
+ toggle.setAttribute("aria-expanded", expanded ? "true" : "false");
52
+ const label = toggle.querySelector("[data-approval-details-label]") as HTMLElement | null;
53
+ if (label) {
54
+ label.textContent = expanded
55
+ ? (approvalConfig?.hideDetailsLabel ?? "Hide details")
56
+ : (approvalConfig?.showDetailsLabel ?? "Show details");
57
+ }
58
+ const chevronHolder = toggle.querySelector("[data-approval-details-chevron]") as HTMLElement | null;
59
+ if (chevronHolder) {
60
+ chevronHolder.innerHTML = "";
61
+ const chevron = renderLucideIcon(expanded ? "chevron-up" : "chevron-down", 14, "currentColor", 2);
62
+ if (chevron) {
63
+ chevronHolder.appendChild(chevron);
64
+ }
65
+ }
66
+ };
67
+
68
+ /**
69
+ * Sync the technical-details section (toggle label/chevron + visibility) with
70
+ * `approvalDetailsExpansionState`. Called from the ui.ts expansion event
71
+ * delegation after a toggle click.
72
+ */
73
+ export const updateApprovalDetailsUI = (
74
+ messageId: string,
75
+ bubble: HTMLElement,
76
+ config?: AgentWidgetConfig
77
+ ): void => {
78
+ const toggle = bubble.querySelector('button[data-bubble-type="approval"]') as HTMLElement | null;
79
+ const details = bubble.querySelector("[data-approval-details]") as HTMLElement | null;
80
+ if (!toggle || !details) return;
81
+ const expanded = isDetailsExpanded(messageId, config);
82
+ applyDetailsToggleState(toggle, expanded, config);
83
+ details.style.display = expanded ? "" : "none";
84
+ };
5
85
 
6
86
  /**
7
87
  * Update an existing approval bubble's UI after status changes.
@@ -96,6 +176,10 @@ export const createApprovalBubble = (
96
176
  // Apply styling — use semantic tokens with config overrides
97
177
  bubble.style.backgroundColor = approvalConfig?.backgroundColor ?? "var(--persona-approval-bg, #fefce8)";
98
178
  bubble.style.borderColor = approvalConfig?.borderColor ?? "var(--persona-approval-border, #fef08a)";
179
+ bubble.style.boxShadow =
180
+ approvalConfig?.shadow !== undefined
181
+ ? (approvalConfig.shadow.trim() === "" ? "none" : approvalConfig.shadow)
182
+ : "var(--persona-approval-shadow, 0 5px 15px rgba(15, 23, 42, 0.08))";
99
183
 
100
184
  if (!approval) {
101
185
  return bubble;
@@ -156,30 +240,96 @@ export const createApprovalBubble = (
156
240
 
157
241
  content.appendChild(titleRow);
158
242
 
159
- // Description
160
- const description = createElement("p", "persona-text-sm persona-mt-0.5 persona-text-persona-muted");
243
+ // User-facing summary line. The wire `description` is the tool's
244
+ // agent-facing description (prompt prose, usage rules), so it is not shown
245
+ // here — it lives in the collapsible details section below. Label priority:
246
+ // formatDescription → declared WebMCP `title` → humanized tool name →
247
+ // raw description (no tool name at all).
248
+ const isWebMcpTool =
249
+ approval.toolType === "webmcp" ||
250
+ approval.toolName.startsWith(WEBMCP_TOOL_PREFIX);
251
+ const declaredTitle = isWebMcpTool
252
+ ? getWebMcpToolDisplayTitle(approval.toolName)
253
+ : undefined;
254
+ const summaryFromConfig = approvalConfig?.formatDescription?.({
255
+ toolName: approval.toolName,
256
+ toolType: approval.toolType,
257
+ description: approval.description,
258
+ parameters: approval.parameters,
259
+ ...(declaredTitle ? { displayTitle: declaredTitle } : {}),
260
+ });
261
+ const summaryFallsBackToDescription = !approval.toolName;
262
+ const summaryText =
263
+ summaryFromConfig ||
264
+ (summaryFallsBackToDescription
265
+ ? approval.description
266
+ : `The assistant wants to use “${declaredTitle ?? humanizeToolName(approval.toolName)}”.`);
267
+
268
+ const summary = createElement("p", "persona-text-sm persona-mt-0.5 persona-text-persona-muted");
269
+ summary.setAttribute("data-approval-summary", "true");
161
270
  if (approvalConfig?.descriptionColor) {
162
- description.style.color = approvalConfig.descriptionColor;
271
+ summary.style.color = approvalConfig.descriptionColor;
163
272
  }
164
- description.textContent = approval.description;
165
- content.appendChild(description);
166
-
167
- // Parameters block
168
- if (approval.parameters) {
169
- const paramsPre = createElement(
170
- "pre",
171
- "persona-mt-2 persona-text-xs persona-p-2 persona-rounded persona-overflow-x-auto persona-max-h-32 persona-bg-persona-container persona-text-persona-primary"
172
- );
173
- if (approvalConfig?.parameterBackgroundColor) {
174
- paramsPre.style.backgroundColor = approvalConfig.parameterBackgroundColor;
273
+ summary.textContent = summaryText;
274
+ content.appendChild(summary);
275
+
276
+ // Technical details: agent-facing description + raw parameters JSON,
277
+ // collapsed behind a toggle by default (`approval.detailsDisplay`).
278
+ const detailsMode = approvalConfig?.detailsDisplay ?? "collapsed";
279
+ const showDescriptionInDetails = Boolean(approval.description) && !summaryFallsBackToDescription;
280
+ const hasDetails = showDescriptionInDetails || Boolean(approval.parameters);
281
+ if (detailsMode !== "hidden" && hasDetails) {
282
+ const expanded = isDetailsExpanded(message.id, config);
283
+
284
+ const toggle = createElement(
285
+ "button",
286
+ "persona-inline-flex persona-items-center persona-gap-1 persona-mt-1 persona-p-0 persona-border-none persona-bg-transparent persona-text-xs persona-font-medium persona-cursor-pointer persona-text-persona-muted"
287
+ ) as HTMLButtonElement;
288
+ toggle.type = "button";
289
+ toggle.setAttribute("data-expand-header", "true");
290
+ toggle.setAttribute("data-bubble-type", "approval");
291
+ if (approvalConfig?.descriptionColor) {
292
+ toggle.style.color = approvalConfig.descriptionColor;
293
+ }
294
+ const toggleLabel = createElement("span");
295
+ toggleLabel.setAttribute("data-approval-details-label", "true");
296
+ const chevronHolder = createElement("span", "persona-inline-flex persona-items-center");
297
+ chevronHolder.setAttribute("data-approval-details-chevron", "true");
298
+ toggle.append(toggleLabel, chevronHolder);
299
+ applyDetailsToggleState(toggle, expanded, config);
300
+ content.appendChild(toggle);
301
+
302
+ const details = createElement("div");
303
+ details.setAttribute("data-approval-details", "true");
304
+ details.style.display = expanded ? "" : "none";
305
+
306
+ if (showDescriptionInDetails) {
307
+ const description = createElement("p", "persona-text-sm persona-mt-1 persona-text-persona-muted");
308
+ if (approvalConfig?.descriptionColor) {
309
+ description.style.color = approvalConfig.descriptionColor;
310
+ }
311
+ description.textContent = approval.description;
312
+ details.appendChild(description);
175
313
  }
176
- if (approvalConfig?.parameterTextColor) {
177
- paramsPre.style.color = approvalConfig.parameterTextColor;
314
+
315
+ if (approval.parameters) {
316
+ const paramsPre = createElement(
317
+ "pre",
318
+ "persona-mt-2 persona-text-xs persona-p-2 persona-rounded persona-overflow-x-auto persona-max-h-32 persona-bg-persona-container persona-text-persona-primary"
319
+ );
320
+ if (approvalConfig?.parameterBackgroundColor) {
321
+ paramsPre.style.backgroundColor = approvalConfig.parameterBackgroundColor;
322
+ }
323
+ if (approvalConfig?.parameterTextColor) {
324
+ paramsPre.style.color = approvalConfig.parameterTextColor;
325
+ }
326
+ paramsPre.style.fontSize = "0.75rem";
327
+ paramsPre.style.lineHeight = "1rem";
328
+ paramsPre.textContent = formatUnknownValue(approval.parameters);
329
+ details.appendChild(paramsPre);
178
330
  }
179
- paramsPre.style.fontSize = "0.75rem";
180
- paramsPre.style.lineHeight = "1rem";
181
- paramsPre.textContent = formatUnknownValue(approval.parameters);
182
- content.appendChild(paramsPre);
331
+
332
+ content.appendChild(details);
183
333
  }
184
334
 
185
335
  // Action buttons (only shown when pending)
@@ -187,10 +187,13 @@ export const createLauncherButton = (
187
187
 
188
188
  // Apply launcher border and shadow from config (with defaults matching previous Tailwind classes)
189
189
  const defaultBorder = "1px solid var(--persona-border, #e5e7eb)";
190
- const defaultShadow = "var(--persona-shadow, 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1))";
191
-
190
+ const defaultShadow = "var(--persona-launcher-shadow, 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1))";
191
+
192
192
  button.style.border = launcher.border ?? defaultBorder;
193
- button.style.boxShadow = launcher.shadow ?? defaultShadow;
193
+ button.style.boxShadow =
194
+ launcher.shadow !== undefined
195
+ ? (launcher.shadow.trim() === "" ? "none" : launcher.shadow)
196
+ : defaultShadow;
194
197
 
195
198
  if (dockedMode) {
196
199
  // Docked mode uses a 0px column when closed and hides this button; keep no hit target.
@@ -0,0 +1,39 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { createToolBubble } from "./tool-bubble";
5
+ import type { AgentWidgetConfig, AgentWidgetMessage } from "../types";
6
+
7
+ const makeMessage = (): AgentWidgetMessage => ({
8
+ id: "msg-1",
9
+ role: "assistant",
10
+ content: "",
11
+ createdAt: new Date().toISOString(),
12
+ variant: "tool",
13
+ streaming: false,
14
+ toolCall: {
15
+ id: "tool-1",
16
+ name: "get_weather",
17
+ status: "complete",
18
+ args: { city: "SF" },
19
+ },
20
+ });
21
+
22
+ describe("createToolBubble shadow", () => {
23
+ it("falls back to the themeable --persona-tool-bubble-shadow variable when no config.shadow is set", () => {
24
+ const bubble = createToolBubble(makeMessage());
25
+ expect(bubble.style.boxShadow).toContain("--persona-tool-bubble-shadow");
26
+ });
27
+
28
+ it("applies a custom shadow inline from config.toolCall.shadow", () => {
29
+ const config = { toolCall: { shadow: "0 8px 24px rgba(0,0,0,0.15)" } } as AgentWidgetConfig;
30
+ const bubble = createToolBubble(makeMessage(), config);
31
+ expect(bubble.style.boxShadow).toBe("0 8px 24px rgba(0,0,0,0.15)");
32
+ });
33
+
34
+ it('maps an empty shadow string to "none"', () => {
35
+ const config = { toolCall: { shadow: " " } } as AgentWidgetConfig;
36
+ const bubble = createToolBubble(makeMessage(), config);
37
+ expect(bubble.style.boxShadow).toBe("none");
38
+ });
39
+ });
@@ -151,6 +151,10 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
151
151
  if (toolCallConfig.borderRadius) {
152
152
  bubble.style.borderRadius = toolCallConfig.borderRadius;
153
153
  }
154
+ bubble.style.boxShadow =
155
+ toolCallConfig.shadow !== undefined
156
+ ? (toolCallConfig.shadow.trim() === "" ? "none" : toolCallConfig.shadow)
157
+ : "var(--persona-tool-bubble-shadow, 0 5px 15px rgba(15, 23, 42, 0.08))";
154
158
 
155
159
  if (!tool) {
156
160
  return bubble;
@@ -206,6 +206,7 @@ export type RuntypeAgentSSEEvent = {
206
206
  iteration: number;
207
207
  reflection?: string;
208
208
  seq: number;
209
+ timestamp?: string;
209
210
  type: "agent_reflection";
210
211
  } | {
211
212
  activatedCapabilities: Array<string>;
@@ -213,6 +214,7 @@ export type RuntypeAgentSSEEvent = {
213
214
  iteration: number;
214
215
  seq: number;
215
216
  skill: string;
217
+ timestamp?: string;
216
218
  toolCallId: string;
217
219
  type: "agent_skill_loaded";
218
220
  } | ({
@@ -222,6 +224,7 @@ export type RuntypeAgentSSEEvent = {
222
224
  proposalId?: string;
223
225
  seq: number;
224
226
  skill: string;
227
+ timestamp?: string;
225
228
  toolCallId: string;
226
229
  type: "agent_skill_proposed";
227
230
  }) | ({
@@ -255,6 +258,7 @@ export type RuntypeAgentSSEEvent = {
255
258
  iteration?: number;
256
259
  recoverable: boolean;
257
260
  seq: number;
261
+ timestamp?: string;
258
262
  type: "agent_error";
259
263
  } | {
260
264
  executionId: string;
@@ -289,6 +293,7 @@ export type RuntypeFlowSSEEvent = {
289
293
  failedSteps?: number;
290
294
  finalOutput?: string;
291
295
  flowId?: string;
296
+ flowName?: string;
292
297
  output?: unknown;
293
298
  seq?: number;
294
299
  source?: string;
@@ -340,6 +345,7 @@ export type RuntypeFlowSSEEvent = {
340
345
  id?: string;
341
346
  index?: number;
342
347
  name?: string;
348
+ outputVariable?: string;
343
349
  seq?: number;
344
350
  startedAt: string;
345
351
  stepId?: string;
@@ -751,6 +757,7 @@ export type RuntypeDispatchSSEEvent = {
751
757
  iteration: number;
752
758
  reflection?: string;
753
759
  seq: number;
760
+ timestamp?: string;
754
761
  type: "agent_reflection";
755
762
  } | {
756
763
  activatedCapabilities: Array<string>;
@@ -758,6 +765,7 @@ export type RuntypeDispatchSSEEvent = {
758
765
  iteration: number;
759
766
  seq: number;
760
767
  skill: string;
768
+ timestamp?: string;
761
769
  toolCallId: string;
762
770
  type: "agent_skill_loaded";
763
771
  } | ({
@@ -767,6 +775,7 @@ export type RuntypeDispatchSSEEvent = {
767
775
  proposalId?: string;
768
776
  seq: number;
769
777
  skill: string;
778
+ timestamp?: string;
770
779
  toolCallId: string;
771
780
  type: "agent_skill_proposed";
772
781
  }) | ({
@@ -800,6 +809,7 @@ export type RuntypeDispatchSSEEvent = {
800
809
  iteration?: number;
801
810
  recoverable: boolean;
802
811
  seq: number;
812
+ timestamp?: string;
803
813
  type: "agent_error";
804
814
  } | {
805
815
  executionId: string;
@@ -832,6 +842,7 @@ export type RuntypeDispatchSSEEvent = {
832
842
  failedSteps?: number;
833
843
  finalOutput?: string;
834
844
  flowId?: string;
845
+ flowName?: string;
835
846
  output?: unknown;
836
847
  seq?: number;
837
848
  source?: string;
@@ -883,6 +894,7 @@ export type RuntypeDispatchSSEEvent = {
883
894
  id?: string;
884
895
  index?: number;
885
896
  name?: string;
897
+ outputVariable?: string;
886
898
  seq?: number;
887
899
  startedAt: string;
888
900
  stepId?: string;
package/src/index-core.ts CHANGED
@@ -81,6 +81,7 @@ export type {
81
81
  // Approval types
82
82
  AgentWidgetApproval,
83
83
  AgentWidgetApprovalConfig,
84
+ AgentWidgetApprovalDecisionOptions,
84
85
  // WebMCP — page-discovered tool consumption
85
86
  AgentWidgetWebMcpConfig,
86
87
  ClientToolDefinition,
@@ -0,0 +1,230 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import {
6
+ createPopover,
7
+ getStyleRoot,
8
+ injectStyles,
9
+ isEditableEventTarget,
10
+ } from "./plugin-kit";
11
+
12
+ afterEach(() => {
13
+ document.body.innerHTML = "";
14
+ document.head.querySelectorAll("[data-persona-plugin-style]").forEach((n) => n.remove());
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ const flushMicrotasks = () => Promise.resolve();
19
+
20
+ describe("injectStyles / getStyleRoot", () => {
21
+ it("injects a <style> into the document head for a connected light-DOM node", () => {
22
+ const el = document.createElement("div");
23
+ document.body.appendChild(el);
24
+
25
+ injectStyles(el, "kit-light", ".x { color: red; }");
26
+
27
+ const style = document.head.querySelector(
28
+ 'style[data-persona-plugin-style="kit-light"]'
29
+ );
30
+ expect(style).not.toBeNull();
31
+ expect(style?.textContent).toContain("color: red");
32
+ });
33
+
34
+ it("is idempotent — repeated calls add only one <style>", () => {
35
+ const el = document.createElement("div");
36
+ document.body.appendChild(el);
37
+
38
+ injectStyles(el, "kit-once", ".a {}");
39
+ injectStyles(el, "kit-once", ".a {}");
40
+ injectStyles(el, "kit-once", ".a {}");
41
+
42
+ expect(
43
+ document.head.querySelectorAll('style[data-persona-plugin-style="kit-once"]').length
44
+ ).toBe(1);
45
+ });
46
+
47
+ it("injects into the shadow root for a node inside shadow DOM", () => {
48
+ const host = document.createElement("div");
49
+ document.body.appendChild(host);
50
+ const shadow = host.attachShadow({ mode: "open" });
51
+ const el = document.createElement("div");
52
+ shadow.appendChild(el);
53
+
54
+ expect(getStyleRoot(el)).toBe(shadow);
55
+
56
+ injectStyles(el, "kit-shadow", ".s {}");
57
+
58
+ expect(shadow.querySelector('style[data-persona-plugin-style="kit-shadow"]')).not.toBeNull();
59
+ // Must NOT leak into the document head.
60
+ expect(
61
+ document.head.querySelector('style[data-persona-plugin-style="kit-shadow"]')
62
+ ).toBeNull();
63
+ });
64
+
65
+ it("defers shadow injection for a node that mounts after the call", async () => {
66
+ const host = document.createElement("div");
67
+ document.body.appendChild(host);
68
+ const shadow = host.attachShadow({ mode: "open" });
69
+
70
+ // Build detached, inject, then mount into the shadow root (mirrors a plugin
71
+ // returning an element the widget mounts afterward).
72
+ const el = document.createElement("div");
73
+ injectStyles(el, "kit-deferred", ".d {}");
74
+ shadow.appendChild(el);
75
+
76
+ await flushMicrotasks();
77
+
78
+ expect(shadow.querySelector('style[data-persona-plugin-style="kit-deferred"]')).not.toBeNull();
79
+ });
80
+
81
+ it("accepts an explicit Document or ShadowRoot target", () => {
82
+ const host = document.createElement("div");
83
+ document.body.appendChild(host);
84
+ const shadow = host.attachShadow({ mode: "open" });
85
+
86
+ injectStyles(shadow, "kit-explicit", ".e {}");
87
+
88
+ expect(shadow.querySelector('style[data-persona-plugin-style="kit-explicit"]')).not.toBeNull();
89
+ });
90
+ });
91
+
92
+ describe("createPopover", () => {
93
+ const setup = () => {
94
+ const anchor = document.createElement("button");
95
+ anchor.textContent = "anchor";
96
+ document.body.appendChild(anchor);
97
+ const content = document.createElement("div");
98
+ content.textContent = "menu";
99
+ return { anchor, content };
100
+ };
101
+
102
+ it("mounts and positions the content on open, removes it on close", () => {
103
+ const { anchor, content } = setup();
104
+ const popover = createPopover({ anchor, content });
105
+
106
+ expect(popover.isOpen).toBe(false);
107
+ expect(content.isConnected).toBe(false);
108
+
109
+ popover.open();
110
+ expect(popover.isOpen).toBe(true);
111
+ expect(content.isConnected).toBe(true);
112
+ expect(content.style.position).toBe("fixed");
113
+ expect(content.style.top).not.toBe("");
114
+ expect(content.style.left).not.toBe("");
115
+
116
+ popover.close();
117
+ expect(popover.isOpen).toBe(false);
118
+ expect(content.isConnected).toBe(false);
119
+ });
120
+
121
+ it("toggle flips open/closed", () => {
122
+ const { anchor, content } = setup();
123
+ const popover = createPopover({ anchor, content });
124
+
125
+ popover.toggle();
126
+ expect(popover.isOpen).toBe(true);
127
+ popover.toggle();
128
+ expect(popover.isOpen).toBe(false);
129
+ });
130
+
131
+ it("closes on outside pointerdown and fires onDismiss", () => {
132
+ vi.useFakeTimers();
133
+ const onDismiss = vi.fn();
134
+ const { anchor, content } = setup();
135
+ const popover = createPopover({ anchor, content, onDismiss });
136
+
137
+ popover.open();
138
+ // Arming is deferred via setTimeout so the opening click can't dismiss it.
139
+ vi.runAllTimers();
140
+
141
+ document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
142
+
143
+ expect(popover.isOpen).toBe(false);
144
+ expect(onDismiss).toHaveBeenCalledWith("outside");
145
+ vi.useRealTimers();
146
+ });
147
+
148
+ it("does not dismiss when the pointerdown is inside the content", () => {
149
+ vi.useFakeTimers();
150
+ const onDismiss = vi.fn();
151
+ const { anchor, content } = setup();
152
+ const popover = createPopover({ anchor, content, onDismiss });
153
+
154
+ popover.open();
155
+ vi.runAllTimers();
156
+
157
+ content.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true }));
158
+
159
+ expect(popover.isOpen).toBe(true);
160
+ expect(onDismiss).not.toHaveBeenCalled();
161
+ vi.useRealTimers();
162
+ });
163
+
164
+ it("auto-closes when the anchor leaves the DOM on reposition", () => {
165
+ const onDismiss = vi.fn();
166
+ const { anchor, content } = setup();
167
+ const popover = createPopover({ anchor, content, onDismiss });
168
+
169
+ popover.open();
170
+ anchor.remove();
171
+ window.dispatchEvent(new Event("resize"));
172
+
173
+ expect(popover.isOpen).toBe(false);
174
+ expect(onDismiss).toHaveBeenCalledWith("anchor-removed");
175
+ });
176
+
177
+ it("mounts into the anchor's shadow root when shadowed", () => {
178
+ const host = document.createElement("div");
179
+ document.body.appendChild(host);
180
+ const shadow = host.attachShadow({ mode: "open" });
181
+ const anchor = document.createElement("button");
182
+ shadow.appendChild(anchor);
183
+ const content = document.createElement("div");
184
+
185
+ const popover = createPopover({ anchor, content });
186
+ popover.open();
187
+
188
+ expect(content.getRootNode()).toBe(shadow);
189
+ });
190
+
191
+ it("destroy removes content and detaches listeners", () => {
192
+ vi.useFakeTimers();
193
+ const onDismiss = vi.fn();
194
+ const { anchor, content } = setup();
195
+ const popover = createPopover({ anchor, content, onDismiss });
196
+
197
+ popover.open();
198
+ vi.runAllTimers();
199
+ popover.destroy();
200
+
201
+ expect(content.isConnected).toBe(false);
202
+ // Listeners are gone, so a later outside pointerdown is a no-op.
203
+ document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
204
+ expect(onDismiss).not.toHaveBeenCalled();
205
+ vi.useRealTimers();
206
+ });
207
+ });
208
+
209
+ describe("isEditableEventTarget", () => {
210
+ it("is true for input / textarea / contenteditable targets", () => {
211
+ const input = document.createElement("input");
212
+ document.body.appendChild(input);
213
+ const event = new Event("keydown", { bubbles: true, composed: true });
214
+ input.dispatchEvent(event);
215
+ // jsdom keeps composedPath populated only during dispatch; assert via a
216
+ // synthetic event whose composedPath we can rely on.
217
+ const synthetic = {
218
+ composedPath: () => [input, document.body],
219
+ } as unknown as Event;
220
+ expect(isEditableEventTarget(synthetic)).toBe(true);
221
+ });
222
+
223
+ it("is false for a plain button target", () => {
224
+ const button = document.createElement("button");
225
+ const synthetic = {
226
+ composedPath: () => [button, document.body],
227
+ } as unknown as Event;
228
+ expect(isEditableEventTarget(synthetic)).toBe(false);
229
+ });
230
+ });