@runtypelabs/persona 4.19.0 → 4.21.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 (77) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-CG-bLFmk.d.cts → types-B_JdVK9o.d.cts} +2 -0
  4. package/dist/animations/{types-CG-bLFmk.d.ts → types-B_JdVK9o.d.ts} +2 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +2 -0
  8. package/dist/animations-extra.d.ts +2 -0
  9. package/dist/approval-ui.d.cts +99 -5
  10. package/dist/approval-ui.d.ts +99 -5
  11. package/dist/artifacts-ui.d.cts +99 -5
  12. package/dist/artifacts-ui.d.ts +99 -5
  13. package/dist/codegen.cjs +1 -1
  14. package/dist/codegen.js +1 -1
  15. package/dist/context-mentions.d.cts +99 -5
  16. package/dist/context-mentions.d.ts +99 -5
  17. package/dist/event-stream-view.d.cts +99 -5
  18. package/dist/event-stream-view.d.ts +99 -5
  19. package/dist/forms-ui.d.cts +123 -9
  20. package/dist/forms-ui.d.ts +123 -9
  21. package/dist/history-view.d.cts +2 -0
  22. package/dist/history-view.d.ts +2 -0
  23. package/dist/index.cjs +51 -51
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +128 -10
  26. package/dist/index.d.ts +128 -10
  27. package/dist/index.global.js +34 -34
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +51 -51
  30. package/dist/index.js.map +1 -1
  31. package/dist/launcher.global.js.map +1 -1
  32. package/dist/session-reconnect.d.cts +99 -5
  33. package/dist/session-reconnect.d.ts +99 -5
  34. package/dist/smart-dom-reader.d.cts +99 -5
  35. package/dist/smart-dom-reader.d.ts +99 -5
  36. package/dist/theme-editor-preview.cjs +53 -53
  37. package/dist/theme-editor-preview.d.cts +99 -5
  38. package/dist/theme-editor-preview.d.ts +99 -5
  39. package/dist/theme-editor-preview.js +31 -31
  40. package/dist/theme-editor.d.cts +99 -5
  41. package/dist/theme-editor.d.ts +99 -5
  42. package/dist/theme-reference.cjs +1 -1
  43. package/dist/theme-reference.d.cts +46 -5
  44. package/dist/theme-reference.d.ts +46 -5
  45. package/dist/theme-reference.js +1 -1
  46. package/dist/webmcp-runtime.d.cts +16 -0
  47. package/dist/webmcp-runtime.d.ts +16 -0
  48. package/dist/widget.css +1 -1
  49. package/package.json +1 -1
  50. package/src/client-visitor-history.test.ts +342 -3
  51. package/src/client.test.ts +141 -3
  52. package/src/client.ts +157 -39
  53. package/src/components/composer-actions.ts +27 -1
  54. package/src/components/composer-model-picker.ts +4 -14
  55. package/src/components/composer-overflow-menu.ts +14 -0
  56. package/src/components/composer-parts.ts +20 -0
  57. package/src/generated/runtype-openapi-contract.ts +2 -0
  58. package/src/index-core.ts +2 -0
  59. package/src/session.ts +139 -103
  60. package/src/session.webmcp.test.ts +48 -1
  61. package/src/styles/widget-styles.test.ts +83 -1
  62. package/src/styles/widget.css +50 -14
  63. package/src/theme-reference.ts +1 -1
  64. package/src/types/theme.ts +47 -5
  65. package/src/types.ts +63 -0
  66. package/src/ui.composer-action-visibility.test.ts +202 -0
  67. package/src/ui.composer-models.test.ts +37 -0
  68. package/src/ui.composer-modes.test.ts +82 -0
  69. package/src/ui.composer-overflow.test.ts +78 -0
  70. package/src/ui.history-lifecycle.test.ts +2 -2
  71. package/src/ui.ts +77 -6
  72. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +1633 -686
  73. package/src/utils/composer-modes.test.ts +33 -0
  74. package/src/utils/composer-modes.ts +19 -2
  75. package/src/utils/tokens-parity.test.ts +20 -0
  76. package/src/utils/tokens.test.ts +76 -0
  77. package/src/utils/tokens.ts +4 -4
package/src/session.ts CHANGED
@@ -1553,6 +1553,7 @@ export class AgentWidgetSession {
1553
1553
 
1554
1554
  /** Conversation-scoped caches that must not leak across records. */
1555
1555
  private resetConversationScopedState(): void {
1556
+ this.historyInternals.setStoredResumableHandle?.(null);
1556
1557
  this.agentExecution = null;
1557
1558
  this.clearArtifactState();
1558
1559
  this.webMcpInflightKeys.clear();
@@ -3341,9 +3342,16 @@ export class AgentWidgetSession {
3341
3342
  }
3342
3343
 
3343
3344
  try {
3344
- const response = await this.client.resumeFlow(executionId, {
3345
- [toolName]: answer,
3346
- });
3345
+ const response = await this.client.resumeFlow(
3346
+ executionId,
3347
+ { [toolName]: answer },
3348
+ {
3349
+ after:
3350
+ this.resumable?.executionId === executionId
3351
+ ? this.resumable.lastEventId
3352
+ : undefined,
3353
+ },
3354
+ );
3347
3355
 
3348
3356
  if (!response.ok) {
3349
3357
  const errorData = await response.json().catch(() => null);
@@ -3566,10 +3574,12 @@ export class AgentWidgetSession {
3566
3574
  /**
3567
3575
  * Resolve one or more parallel local-tool awaits sharing one paused
3568
3576
  * executionId with a SINGLE `/resume` (core#3878); `resolveWebMcpToolCall`
3569
- * delegates size-1 resolves here after its guards. Each call is executed
3570
- * against the page registry concurrently: every gated call renders its own
3571
- * native approval bubble, and a sibling's confirm Promise never blocks
3572
- * another's execution. Outputs are keyed by per-call `webMcpToolCallId`
3577
+ * delegates size-1 resolves here after its guards. By default each call is
3578
+ * executed against the page registry concurrently: every gated call renders
3579
+ * its own native approval bubble, and a sibling's confirm Promise never
3580
+ * blocks another's execution. With `webmcp.execution: "sequential"` the
3581
+ * calls run one at a time in emission order instead (see the config docs
3582
+ * for when that matters). Outputs are keyed by per-call `webMcpToolCallId`
3573
3583
  * (server prefers it over tool name; name-keying remains the fallback for
3574
3584
  * legacy single/distinct-tool turns), so two calls to the SAME tool no longer
3575
3585
  * collide. The server is tolerant: any call we omit (declined-after-abort,
@@ -3599,111 +3609,125 @@ export class AgentWidgetSession {
3599
3609
  this.webMcpResolveControllers.add(batchController);
3600
3610
  this.setStreaming(true);
3601
3611
 
3602
- // Phase 1: execute every pending call concurrently. A null result means
3603
- // the call was deduped, aborted, or threw; it's omitted from the resume and
3604
- // (per the tolerant server) re-pauses for retry.
3605
- const executed = await Promise.all(
3606
- snapshots.map(async (toolMessage) => {
3607
- const wireToolName = toolMessage.toolCall?.name;
3608
- const callId = toolMessage.toolCall?.id;
3609
- if (!wireToolName || !callId) return null;
3610
-
3611
- const dedupeKey = `${executionId}:${callId}`;
3612
- if (
3613
- this.webMcpInflightKeys.has(dedupeKey) ||
3614
- this.webMcpResolvedKeys.has(dedupeKey) ||
3615
- this.isSuggestRepliesAlreadyResolved(toolMessage)
3616
- ) {
3617
- return null;
3618
- }
3619
- this.webMcpInflightKeys.add(dedupeKey);
3620
- claimedKeys.push(dedupeKey);
3621
-
3622
- // Clear the awaiting flag and keep the tool bubble running while the
3623
- // browser-side WebMCP promise is in flight. The initial `step_await`
3624
- // only means the server paused for a local tool; it is not completion.
3625
- const startedAt = this.markWebMcpToolRunning(toolMessage);
3626
-
3627
- // Per-call id wins for resume keying; fall back to the wire tool name
3628
- // for legacy servers that don't emit `webMcpToolCallId`.
3629
- const resumeKey =
3630
- toolMessage.agentMetadata?.webMcpToolCallId ?? wireToolName;
3631
-
3632
- // Built-in fire-and-forget tool: no bridge, no confirm gate, no
3633
- // browser-side execution: the chips render from the message list and
3634
- // the canned output joins the batch's single /resume.
3635
- if (wireToolName === SUGGEST_REPLIES_TOOL_NAME) {
3636
- return {
3637
- dedupeKey,
3638
- resumeKey,
3639
- output: suggestRepliesToolResult(),
3640
- toolMessage,
3641
- startedAt,
3642
- completedAt: Date.now(),
3643
- };
3644
- }
3612
+ // Phase 1: execute every pending call. A null result means the call was
3613
+ // deduped, aborted, or threw; it's omitted from the resume and (per the
3614
+ // tolerant server) re-pauses for retry.
3615
+ const executeOne = async (
3616
+ toolMessage: AgentWidgetMessage,
3617
+ ): Promise<ExecutedWebMcpTool | null> => {
3618
+ const wireToolName = toolMessage.toolCall?.name;
3619
+ const callId = toolMessage.toolCall?.id;
3620
+ if (!wireToolName || !callId) return null;
3621
+
3622
+ const dedupeKey = `${executionId}:${callId}`;
3623
+ if (
3624
+ this.webMcpInflightKeys.has(dedupeKey) ||
3625
+ this.webMcpResolvedKeys.has(dedupeKey) ||
3626
+ this.isSuggestRepliesAlreadyResolved(toolMessage)
3627
+ ) {
3628
+ return null;
3629
+ }
3630
+ this.webMcpInflightKeys.add(dedupeKey);
3631
+ claimedKeys.push(dedupeKey);
3632
+
3633
+ // Clear the awaiting flag and keep the tool bubble running while the
3634
+ // browser-side WebMCP promise is in flight. The initial `step_await`
3635
+ // only means the server paused for a local tool; it is not completion.
3636
+ const startedAt = this.markWebMcpToolRunning(toolMessage);
3637
+
3638
+ // Per-call id wins for resume keying; fall back to the wire tool name
3639
+ // for legacy servers that don't emit `webMcpToolCallId`.
3640
+ const resumeKey =
3641
+ toolMessage.agentMetadata?.webMcpToolCallId ?? wireToolName;
3642
+
3643
+ // Built-in fire-and-forget tool: no bridge, no confirm gate, no
3644
+ // browser-side execution: the chips render from the message list and
3645
+ // the canned output joins the batch's single /resume.
3646
+ if (wireToolName === SUGGEST_REPLIES_TOOL_NAME) {
3647
+ return {
3648
+ dedupeKey,
3649
+ resumeKey,
3650
+ output: suggestRepliesToolResult(),
3651
+ toolMessage,
3652
+ startedAt,
3653
+ completedAt: Date.now(),
3654
+ };
3655
+ }
3645
3656
 
3646
- const execPromise = this.client.executeWebMcpToolCall(
3647
- wireToolName,
3648
- toolMessage.toolCall?.args,
3649
- batchController.signal,
3650
- );
3657
+ const execPromise = this.client.executeWebMcpToolCall(
3658
+ wireToolName,
3659
+ toolMessage.toolCall?.args,
3660
+ batchController.signal,
3661
+ );
3651
3662
 
3652
- let output: unknown;
3653
- if (!execPromise) {
3654
- output = {
3655
- isError: true,
3656
- content: [
3657
- { type: "text", text: "WebMCP not enabled on this widget." },
3658
- ],
3659
- };
3660
- } else {
3661
- try {
3662
- output = await execPromise;
3663
- } catch (error) {
3664
- const isAbortError =
3665
- error instanceof Error &&
3666
- (error.name === "AbortError" ||
3667
- error.message.includes("aborted") ||
3668
- error.message.includes("abort"));
3669
- if (!isAbortError) {
3670
- this.callbacks.onError?.(
3671
- error instanceof Error ? error : new Error(String(error)),
3672
- );
3673
- }
3674
- this.markWebMcpToolComplete(
3675
- toolMessage,
3676
- buildWebMcpErrorResult(
3677
- isAbortError
3678
- ? "Aborted by cancel()"
3679
- : getWebMcpErrorMessage(error),
3680
- ),
3681
- startedAt,
3663
+ let output: unknown;
3664
+ if (!execPromise) {
3665
+ output = {
3666
+ isError: true,
3667
+ content: [
3668
+ { type: "text", text: "WebMCP not enabled on this widget." },
3669
+ ],
3670
+ };
3671
+ } else {
3672
+ try {
3673
+ output = await execPromise;
3674
+ } catch (error) {
3675
+ const isAbortError =
3676
+ error instanceof Error &&
3677
+ (error.name === "AbortError" ||
3678
+ error.message.includes("aborted") ||
3679
+ error.message.includes("abort"));
3680
+ if (!isAbortError) {
3681
+ this.callbacks.onError?.(
3682
+ error instanceof Error ? error : new Error(String(error)),
3682
3683
  );
3683
- // Release the dedupe claim so a re-emit can retry this call.
3684
- this.webMcpInflightKeys.delete(dedupeKey);
3685
- return null;
3686
3684
  }
3687
- }
3688
- if (batchController.signal.aborted) {
3689
3685
  this.markWebMcpToolComplete(
3690
3686
  toolMessage,
3691
- buildWebMcpErrorResult("Aborted by cancel()"),
3687
+ buildWebMcpErrorResult(
3688
+ isAbortError
3689
+ ? "Aborted by cancel()"
3690
+ : getWebMcpErrorMessage(error),
3691
+ ),
3692
3692
  startedAt,
3693
3693
  );
3694
+ // Release the dedupe claim so a re-emit can retry this call.
3694
3695
  this.webMcpInflightKeys.delete(dedupeKey);
3695
3696
  return null;
3696
3697
  }
3697
- return {
3698
- dedupeKey,
3699
- resumeKey,
3700
- output,
3698
+ }
3699
+ if (batchController.signal.aborted) {
3700
+ this.markWebMcpToolComplete(
3701
3701
  toolMessage,
3702
+ buildWebMcpErrorResult("Aborted by cancel()"),
3702
3703
  startedAt,
3703
- completedAt: Date.now(),
3704
- };
3705
- }),
3706
- );
3704
+ );
3705
+ this.webMcpInflightKeys.delete(dedupeKey);
3706
+ return null;
3707
+ }
3708
+ return {
3709
+ dedupeKey,
3710
+ resumeKey,
3711
+ output,
3712
+ toolMessage,
3713
+ startedAt,
3714
+ completedAt: Date.now(),
3715
+ };
3716
+ };
3717
+
3718
+ // Parallel (default): a gated sibling's approval never blocks another's
3719
+ // execution. Sequential: one at a time in emission order, so tools that
3720
+ // share page state (a canvas, a form) don't interleave; the next call
3721
+ // starts only once the previous one has settled, approval included.
3722
+ let executed: Array<ExecutedWebMcpTool | null>;
3723
+ if (this.config.webmcp?.execution === "sequential") {
3724
+ executed = [];
3725
+ for (const toolMessage of snapshots) {
3726
+ executed.push(await executeOne(toolMessage));
3727
+ }
3728
+ } else {
3729
+ executed = await Promise.all(snapshots.map(executeOne));
3730
+ }
3707
3731
 
3708
3732
  let ready: ExecutedWebMcpTool[] = [];
3709
3733
  try {
@@ -3720,6 +3744,10 @@ export class AgentWidgetSession {
3720
3744
 
3721
3745
  const response = await this.client.resumeFlow(executionId, toolOutputs, {
3722
3746
  signal: batchController.signal,
3747
+ after:
3748
+ this.resumable?.executionId === executionId
3749
+ ? this.resumable.lastEventId
3750
+ : undefined,
3723
3751
  });
3724
3752
  if (!response.ok) {
3725
3753
  const errorData = await response.json().catch(() => null);
@@ -3895,7 +3923,13 @@ export class AgentWidgetSession {
3895
3923
  const response = await this.client.resumeFlow(
3896
3924
  executionId,
3897
3925
  { [resumeKey]: output },
3898
- { signal: options?.signal },
3926
+ {
3927
+ signal: options?.signal,
3928
+ after:
3929
+ this.resumable?.executionId === executionId
3930
+ ? this.resumable.lastEventId
3931
+ : undefined,
3932
+ },
3899
3933
  );
3900
3934
  if (!response.ok) {
3901
3935
  const errorData = await response.json().catch(() => null);
@@ -4323,9 +4357,11 @@ export class AgentWidgetSession {
4323
4357
  if (event.status === "connecting") {
4324
4358
  this.setStreaming(true);
4325
4359
  } else if (event.status === "idle" || event.status === "error") {
4326
- // Graceful terminal or terminal error: the durable turn is over, so
4327
- // drop the resume handle (no reconnect should arm after this).
4328
- this.clearResumable();
4360
+ // A client-tool pause is an intentional stream end but NOT a terminal:
4361
+ // retain its cursor so `/client/resume` can attach after the await
4362
+ // frame instead of replaying the whole turn. Real terminals and
4363
+ // ordinary non-durable idles still clear the handle.
4364
+ if (!this.isAwaitPending()) this.clearResumable();
4329
4365
  // Keep the typing indicator up while a WebMCP resolve is still in
4330
4366
  // flight: in a chained turn the intermediate resume stream ends with an
4331
4367
  // idle status, but the successor tool is still executing. The resolve's
@@ -13,9 +13,10 @@ const makeSession = (overrides?: {
13
13
  resumeOk?: boolean;
14
14
  isOperational?: boolean;
15
15
  executeImpl?: () => Promise<WebMcpToolResult>;
16
+ webmcp?: Record<string, unknown>;
16
17
  }) => {
17
18
  const session = new AgentWidgetSession(
18
- { apiUrl: "http://test", webmcp: { enabled: true } },
19
+ { apiUrl: "http://test", webmcp: { enabled: true, ...overrides?.webmcp } },
19
20
  {
20
21
  onMessagesChanged: () => undefined,
21
22
  onStatusChanged: () => undefined,
@@ -949,6 +950,52 @@ describe("AgentWidgetSession: WebMCP parallel batched resume (core#3878)", () =>
949
950
  expect(Object.keys(toolOutputs).sort()).toEqual(["toolu_A", "toolu_B"]);
950
951
  });
951
952
 
953
+ it("webmcp.execution: 'sequential' runs siblings one at a time, in emission order", async () => {
954
+ // Paint-style tools share page state (select tool + color, then draw), so
955
+ // a multi-call turn must not interleave. The second execute must not even
956
+ // START until the first has resolved; the batch still posts ONE /resume.
957
+ let releaseA: (r: WebMcpToolResult) => void = () => undefined;
958
+ let releaseB: (r: WebMcpToolResult) => void = () => undefined;
959
+ const pA = new Promise<WebMcpToolResult>((r) => (releaseA = r));
960
+ const pB = new Promise<WebMcpToolResult>((r) => (releaseB = r));
961
+ const started: string[] = [];
962
+
963
+ const { session, resumeSpy } = makeSession({
964
+ webmcp: { execution: "sequential" },
965
+ });
966
+ const client = (session as unknown as { client: Record<string, unknown> })
967
+ .client;
968
+ (client.executeWebMcpToolCall as ReturnType<typeof vi.fn>).mockImplementation(
969
+ (_name: string, args: { sku: string }) => {
970
+ started.push(args.sku);
971
+ return args.sku === "SHOE-001" ? pA : pB;
972
+ },
973
+ );
974
+
975
+ feed(session, parallelAwait("toolu_A", "SHOE-001"));
976
+ feed(session, parallelAwait("toolu_B", "SHOE-007"));
977
+ endStream(session);
978
+ await flushMicrotasks();
979
+
980
+ // Only the first call is in flight; the second waits on it.
981
+ expect(started).toEqual(["SHOE-001"]);
982
+ expect(resumeSpy).not.toHaveBeenCalled();
983
+
984
+ releaseA({ content: [{ type: "text", text: "a" }] });
985
+ await flushMicrotasks();
986
+ expect(started).toEqual(["SHOE-001", "SHOE-007"]);
987
+ expect(resumeSpy).not.toHaveBeenCalled();
988
+
989
+ releaseB({ content: [{ type: "text", text: "b" }] });
990
+ await flushMicrotasks();
991
+ expect(resumeSpy).toHaveBeenCalledTimes(1);
992
+ const toolOutputs = (resumeSpy.mock.calls[0]! as unknown[])[1] as Record<
993
+ string,
994
+ unknown
995
+ >;
996
+ expect(Object.keys(toolOutputs).sort()).toEqual(["toolu_A", "toolu_B"]);
997
+ });
998
+
952
999
  it("records elapsed time for each resolved tool in a batched WebMCP resume", async () => {
953
1000
  vi.useFakeTimers();
954
1001
  vi.setSystemTime(1_000);
@@ -585,6 +585,38 @@ describe("overflow menu row shading", () => {
585
585
  );
586
586
  expect(widgetCss).toContain(".persona-composer-overflow-menu__slot:hover");
587
587
  });
588
+
589
+ it("reads every overflowMenu token as a full path over the stock fallback", () => {
590
+ const at = widgetCss.indexOf(".persona-composer-overflow-menu {");
591
+ expect(at, "overflow menu panel rule not found").toBeGreaterThan(-1);
592
+ const panel = widgetCss.slice(at, widgetCss.indexOf("}", at));
593
+
594
+ expect(panel).toContain(
595
+ "--persona-components-composer-overflowMenu-background,"
596
+ );
597
+ expect(panel).toContain("var(--persona-surface, #ffffff)");
598
+ expect(panel).toContain(
599
+ "--persona-components-composer-overflowMenu-borderColor,"
600
+ );
601
+ expect(panel).toContain("var(--persona-border, #e5e7eb)");
602
+ expect(panel).toContain(
603
+ "--persona-components-composer-overflowMenu-borderRadius,"
604
+ );
605
+ expect(panel).toContain(
606
+ "--persona-components-composer-overflowMenu-foreground,"
607
+ );
608
+ expect(panel).toContain(
609
+ "--persona-components-composer-overflowMenu-shadow,"
610
+ );
611
+ });
612
+
613
+ it("keeps the portaled panel rule unprefixed", () => {
614
+ // createPopover mounts the panel outside the widget root, so a
615
+ // `[data-persona-root]` prefix would never match it.
616
+ expect(widgetCss).not.toContain(
617
+ "[data-persona-root] .persona-composer-overflow-menu"
618
+ );
619
+ });
588
620
  });
589
621
 
590
622
  describe("composer control focus rings", () => {
@@ -658,7 +690,11 @@ describe("model picker chevron", () => {
658
690
 
659
691
  it("colors the chevron from the theme token, never a baked-in hex", () => {
660
692
  const rule = ruleFor(".persona-composer-model-picker-chevron");
661
- expect(rule).toContain("background-color: var(--persona-button-ghost-fg");
693
+ // Same chain as the label, so the glyph and the text never diverge.
694
+ expect(rule).toContain(
695
+ "--persona-components-composer-modelPicker-labelColor"
696
+ );
697
+ expect(rule).toContain("var(--persona-button-ghost-fg");
662
698
  // The mask only needs opaque alpha, so the SVG's own stroke is not a color.
663
699
  expect(rule).toContain("mask-image");
664
700
  expect(rule).toContain("-webkit-mask-image");
@@ -840,6 +876,52 @@ describe("model picker popover", () => {
840
876
  });
841
877
  });
842
878
 
879
+ describe("model picker closed control surface", () => {
880
+ const ruleFor = (selector: string): string => {
881
+ const at = widgetCss.indexOf(selector);
882
+ expect(at, `selector not found: ${selector}`).toBeGreaterThan(-1);
883
+ return widgetCss.slice(at, widgetCss.indexOf("}", at));
884
+ };
885
+
886
+ it("reads the surface tokens over the ghost button fallbacks", () => {
887
+ // One vocabulary for both presentations: the native select and the popover
888
+ // trigger share this rule, so a page that stays native still themes.
889
+ const rule = ruleFor("[data-persona-root] .persona-composer-model-picker {");
890
+ expect(rule).toContain(
891
+ "--persona-components-composer-modelPicker-background,"
892
+ );
893
+ expect(rule).toContain("var(--persona-button-ghost-bg, transparent)");
894
+ expect(rule).toContain(
895
+ "--persona-components-composer-modelPicker-borderRadius,"
896
+ );
897
+ expect(rule).toContain("var(--persona-button-ghost-radius");
898
+ expect(rule).toContain(
899
+ "--persona-components-composer-modelPicker-labelColor,"
900
+ );
901
+ expect(rule).toContain("var(--persona-button-ghost-fg");
902
+ });
903
+
904
+ it("draws borderColor as an inset ring so the control never resizes", () => {
905
+ // A real border would eat 1px of the content box under `border-box` sizing
906
+ // and shift the label on every theme that leaves the token unset.
907
+ const rule = ruleFor("[data-persona-root] .persona-composer-model-picker {");
908
+ expect(rule).toContain("border: none");
909
+ expect(rule).toContain(
910
+ "box-shadow: inset 0 0 0 1px\n var(--persona-components-composer-modelPicker-borderColor, transparent)"
911
+ );
912
+ });
913
+
914
+ it("keeps hover on its own token so a themed fill survives the pointer", () => {
915
+ const rule = ruleFor(
916
+ "[data-persona-root] .persona-composer-model-picker:hover:not(:disabled) {"
917
+ );
918
+ expect(rule).toContain(
919
+ "--persona-components-composer-modelPicker-hoverBackground,"
920
+ );
921
+ expect(rule).toContain("var(--persona-button-ghost-hover-bg");
922
+ });
923
+ });
924
+
843
925
  describe("composer motion", () => {
844
926
  /** Every `@media (prefers-reduced-motion: no-preference)` block, joined. */
845
927
  const reducedMotionSafeCss = (): string => {
@@ -1371,7 +1371,8 @@
1371
1371
  *
1372
1372
  * The panel is mounted by createPopover next to the widget root (or inside the
1373
1373
  * shadow root), so its rules are deliberately un-prefixed by
1374
- * `[data-persona-root]` and must stand on their own. */
1374
+ * `[data-persona-root]` and must stand on their own. It sits outside the themed
1375
+ * mount, so the menu forwards its configured tokens onto the panel on open. */
1375
1376
  .persona-composer-overflow-menu {
1376
1377
  display: flex;
1377
1378
  flex-direction: column;
@@ -1380,11 +1381,27 @@
1380
1381
  max-height: 320px;
1381
1382
  overflow-y: auto;
1382
1383
  padding: 4px;
1383
- border-radius: var(--persona-radius-lg, 0.5rem);
1384
- border: 1px solid var(--persona-border, #e5e7eb);
1385
- background: var(--persona-surface, #ffffff);
1386
- color: var(--persona-text, #111827);
1387
- box-shadow: var(--persona-shadow-md, 0 8px 24px rgba(0, 0, 0, 0.12));
1384
+ border-radius: var(
1385
+ --persona-components-composer-overflowMenu-borderRadius,
1386
+ var(--persona-radius-lg, 0.5rem)
1387
+ );
1388
+ border: 1px solid
1389
+ var(
1390
+ --persona-components-composer-overflowMenu-borderColor,
1391
+ var(--persona-border, #e5e7eb)
1392
+ );
1393
+ background: var(
1394
+ --persona-components-composer-overflowMenu-background,
1395
+ var(--persona-surface, #ffffff)
1396
+ );
1397
+ color: var(
1398
+ --persona-components-composer-overflowMenu-foreground,
1399
+ var(--persona-text, #111827)
1400
+ );
1401
+ box-shadow: var(
1402
+ --persona-components-composer-overflowMenu-shadow,
1403
+ var(--persona-shadow-md, 0 8px 24px rgba(0, 0, 0, 0.12))
1404
+ );
1388
1405
  font-family: var(--persona-font-family, inherit);
1389
1406
  }
1390
1407
 
@@ -1598,9 +1615,22 @@
1598
1615
  padding-inline-start: 0.625rem;
1599
1616
  padding-inline-end: 1.75rem;
1600
1617
  border: none;
1601
- border-radius: var(--persona-button-ghost-radius, var(--persona-radius-md, 0.375rem));
1602
- background-color: var(--persona-button-ghost-bg, transparent);
1603
- color: var(--persona-button-ghost-fg, var(--persona-text, #111827));
1618
+ border-radius: var(
1619
+ --persona-components-composer-modelPicker-borderRadius,
1620
+ var(--persona-button-ghost-radius, var(--persona-radius-md, 0.375rem))
1621
+ );
1622
+ background-color: var(
1623
+ --persona-components-composer-modelPicker-background,
1624
+ var(--persona-button-ghost-bg, transparent)
1625
+ );
1626
+ color: var(
1627
+ --persona-components-composer-modelPicker-labelColor,
1628
+ var(--persona-button-ghost-fg, var(--persona-text, #111827))
1629
+ );
1630
+ /* A ring, not a border: `border-box` sizing would pull the label in by 1px on
1631
+ every theme that never sets the token. */
1632
+ box-shadow: inset 0 0 0 1px
1633
+ var(--persona-components-composer-modelPicker-borderColor, transparent);
1604
1634
  font-family: inherit;
1605
1635
  font-size: 0.8125rem;
1606
1636
  cursor: pointer;
@@ -1616,16 +1646,19 @@
1616
1646
  text-align: start;
1617
1647
  }
1618
1648
 
1619
- /* The chevron is a MASK, not a data-URI stroke color: the fill comes from the
1620
- ghost foreground token, so it themes with every other composer glyph. Same
1621
- Lucide `chevron-down` geometry as the icon registry draws. */
1649
+ /* The chevron is a MASK, not a data-URI stroke color: the fill follows the
1650
+ closed control's own text color, so the glyph and the label never diverge.
1651
+ Same Lucide `chevron-down` geometry as the icon registry draws. */
1622
1652
  .persona-composer-model-picker-chevron {
1623
1653
  justify-self: end;
1624
1654
  margin-inline-end: 0.625rem;
1625
1655
  width: 0.875rem;
1626
1656
  height: 0.875rem;
1627
1657
  pointer-events: none;
1628
- background-color: var(--persona-button-ghost-fg, var(--persona-text, #111827));
1658
+ background-color: var(
1659
+ --persona-components-composer-modelPicker-labelColor,
1660
+ var(--persona-button-ghost-fg, var(--persona-text, #111827))
1661
+ );
1629
1662
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
1630
1663
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
1631
1664
  -webkit-mask-repeat: no-repeat;
@@ -1653,7 +1686,10 @@
1653
1686
  }
1654
1687
 
1655
1688
  [data-persona-root] .persona-composer-model-picker:hover:not(:disabled) {
1656
- background-color: var(--persona-button-ghost-hover-bg, rgba(0, 0, 0, 0.05));
1689
+ background-color: var(
1690
+ --persona-components-composer-modelPicker-hoverBackground,
1691
+ var(--persona-button-ghost-hover-bg, rgba(0, 0, 0, 0.05))
1692
+ );
1657
1693
  }
1658
1694
 
1659
1695
  [data-persona-root] .persona-composer-model-picker:disabled {
@@ -173,7 +173,7 @@ export const THEME_TOKEN_DOCS = {
173
173
  'Shared scrollbar appearance for every scroller: thumb (default semantic.colors.border), track (default transparent).',
174
174
  reasoningBubble: 'shadow: reasoning/thinking row box-shadow.',
175
175
  composer:
176
- 'Message input form: shadow (box-shadow), borderColor, padding (form inset), gap (textarea row to actions row), fontSize and lineHeight (textarea type), controlSize (edge of every icon control in the action row: attachment, mention, mic, action buttons, overflow trigger, icon-mode send; default 40px) and controlIconSize (glyph inside them, default 24px). Per-control config keys (sendButton.size, voiceRecognition.iconSize) override controlSize; coarse-pointer devices floor the hit area at 40px. overlayBand paints the band behind an overlaid footer (composer.placement: "overlay"). segmented styles a segmented mode-group track (trackBackground, trackBorderRadius, padding, activeBackground, activeForeground, activeShadow, inactiveForeground, itemPadding); every key is optional and the stylesheet owns the defaults. modelPicker styles the popover model picker (menuBackground, menuBorderRadius, rowHoverBackground, labelColor, descriptionColor, suffixColor), also all optional.',
176
+ 'Message input form: shadow (box-shadow), borderColor, padding (form inset), gap (textarea row to actions row), fontSize and lineHeight (textarea type), controlSize (edge of every icon control in the action row: attachment, mention, mic, action buttons, overflow trigger, icon-mode send; default 40px) and controlIconSize (glyph inside them, default 24px). Per-control config keys (sendButton.size, voiceRecognition.iconSize) override controlSize; coarse-pointer devices floor the hit area at 40px. overlayBand paints the band behind an overlaid footer (composer.placement: "overlay"). segmented styles a segmented mode-group track (trackBackground, trackBorderRadius, padding, activeBackground, activeForeground, activeShadow, inactiveForeground, itemPadding); every key is optional and the stylesheet owns the defaults. modelPicker styles the model picker (background, hoverBackground, borderColor, borderRadius and labelColor on the closed control, which the native select and the popover trigger share; menuBackground, menuBorderRadius, rowHoverBackground, descriptionColor and suffixColor on the popover only), also all optional. overflowMenu styles the composer overflow "+" menu panel (background, borderColor, borderRadius, foreground, shadow), also all optional.',
177
177
  artifact:
178
178
  'toolbar (icon styling, copy menu), tab (background, active states), pane (background, toolbarBackground).',
179
179
  },