@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
@@ -663,21 +663,41 @@ export interface ComposerSegmentedTokens {
663
663
  }
664
664
 
665
665
  /**
666
- * Model picker popover (`composer.modelPicker.presentation: "popover"`).
666
+ * Model picker chrome (`composer.models`).
667
+ *
668
+ * The surface keys (`background`, `hoverBackground`, `borderColor`,
669
+ * `borderRadius`, `labelColor`) style the CLOSED control, so the native
670
+ * `<select>` and the `presentation: "popover"` trigger share one vocabulary.
671
+ * The `menu*`, `rowHoverBackground`, and `descriptionColor` keys style the
672
+ * popover panel and are inert under the native select, as is `suffixColor`,
673
+ * which a `<select>` cannot carry either.
667
674
  *
668
675
  * Every key is optional and unset ones emit nothing: the stylesheet's `var()`
669
676
  * fallbacks own the default look, which derives from the surface and ghost
670
- * button tokens. Inert under the native `<select>` presentation, except
671
- * `suffixColor`, which the native control cannot carry either.
677
+ * button tokens.
672
678
  */
673
679
  export interface ComposerModelPickerTokens {
680
+ /** Closed control fill. @default the ghost button fill (transparent) */
681
+ background?: TokenReference<'color'>;
682
+ /** Closed control fill under hover. @default the ghost hover wash */
683
+ hoverBackground?: TokenReference<'color'>;
684
+ /**
685
+ * Closed control outline, drawn as a 1px inset ring so setting it never
686
+ * resizes the control. @default none
687
+ */
688
+ borderColor?: TokenReference<'color'>;
689
+ /** Closed control corner radius. @default the ghost button radius */
690
+ borderRadius?: TokenReference<'radius'>;
674
691
  /** Panel fill behind the rows. @default semantic.colors.surface */
675
692
  menuBackground?: TokenReference<'color'>;
676
693
  /** Panel corner radius. @default palette.radius.lg */
677
694
  menuBorderRadius?: TokenReference<'radius'>;
678
695
  /** Row fill under hover and the focused option. @default the ghost hover wash */
679
696
  rowHoverBackground?: TokenReference<'color'>;
680
- /** Row label and closed-control label color. @default inherited text */
697
+ /**
698
+ * Row label color, plus the closed control's text and chevron in both
699
+ * presentations. @default the ghost button foreground
700
+ */
681
701
  labelColor?: TokenReference<'color'>;
682
702
  /** Second line under a row label. @default semantic.colors.textMuted */
683
703
  descriptionColor?: TokenReference<'color'>;
@@ -685,6 +705,26 @@ export interface ComposerModelPickerTokens {
685
705
  suffixColor?: TokenReference<'color'>;
686
706
  }
687
707
 
708
+ /**
709
+ * Composer overflow `+` menu panel (`composer.actionOverflow`).
710
+ *
711
+ * Every key is optional and unset ones emit nothing: the stylesheet's `var()`
712
+ * fallbacks own the default look. The panel is portaled outside the themed
713
+ * mount, so the menu forwards the configured values onto it when it opens.
714
+ */
715
+ export interface ComposerOverflowMenuTokens {
716
+ /** Panel fill behind the rows. @default semantic.colors.surface */
717
+ background?: TokenReference<'color'>;
718
+ /** Panel border color. @default semantic.colors.border */
719
+ borderColor?: TokenReference<'color'>;
720
+ /** Panel corner radius. @default palette.radius.lg */
721
+ borderRadius?: TokenReference<'radius'>;
722
+ /** Row text and glyph color. @default semantic.colors.text */
723
+ foreground?: TokenReference<'color'>;
724
+ /** Panel elevation (raw CSS box-shadow, `none` allowed). @default palette.shadows.md */
725
+ shadow?: string;
726
+ }
727
+
688
728
  /** Composer (message input) chrome. */
689
729
  export interface ComposerChromeTokens {
690
730
  /** Box-shadow on the composer form (raw CSS, e.g. `none`). */
@@ -719,8 +759,10 @@ export interface ComposerChromeTokens {
719
759
  overlayBand?: string;
720
760
  /** Segmented mode-group track. */
721
761
  segmented?: ComposerSegmentedTokens;
722
- /** Model picker popover chrome. */
762
+ /** Model picker chrome (closed control plus popover panel). */
723
763
  modelPicker?: ComposerModelPickerTokens;
764
+ /** Overflow `+` menu panel chrome. */
765
+ overflowMenu?: ComposerOverflowMenuTokens;
724
766
  }
725
767
 
726
768
  /**
package/src/types.ts CHANGED
@@ -908,6 +908,22 @@ export type AgentWidgetWebMcpConfig = {
908
908
  * `window.confirm` fallback only applies when no widget UI is attached.
909
909
  */
910
910
  onConfirm?: WebMcpConfirmHandler;
911
+ /**
912
+ * How the widget runs the `webmcp:*` calls of ONE turn when the model emits
913
+ * several at once.
914
+ *
915
+ * - `'parallel'` (default): every call executes concurrently and a gated
916
+ * sibling's approval never blocks another's execution. Right for
917
+ * independent, order-insensitive tools (catalog lookups, add-to-cart).
918
+ * - `'sequential'`: calls execute one at a time, in the order the model
919
+ * emitted them; the next starts only after the previous one settles
920
+ * (including its approval bubble). Use this when the page tools share
921
+ * mutable state that concurrent execution would interleave, e.g. a
922
+ * canvas where every stroke selects a color/tool before drawing.
923
+ *
924
+ * Either way, all outputs still return to the server in a single `/resume`.
925
+ */
926
+ execution?: 'parallel' | 'sequential';
911
927
  };
912
928
 
913
929
  /**
@@ -4007,6 +4023,16 @@ export type ComposerActionPlacement = "start" | "end";
4007
4023
  */
4008
4024
  export type ComposerActionPresentation = "bar" | "overflow" | "auto";
4009
4025
 
4026
+ /**
4027
+ * When a composer action is mounted, as a function of the draft.
4028
+ *
4029
+ * - `"always"` (default): the control is always mounted.
4030
+ * - `"when-empty"`: hidden as soon as the draft holds text or an attachment,
4031
+ * and while a response streams.
4032
+ * - `"when-text"`: the inverse, on the same draft state the send control reads.
4033
+ */
4034
+ export type ComposerActionVisibility = "always" | "when-empty" | "when-text";
4035
+
4010
4036
  /**
4011
4037
  * Fields shared by every composer action.
4012
4038
  *
@@ -4022,6 +4048,13 @@ export type ComposerActionBase = {
4022
4048
  presentation?: ComposerActionPresentation;
4023
4049
  order?: number;
4024
4050
  visible?: boolean | ((state: Readonly<ComposerState>) => boolean);
4051
+ /**
4052
+ * Draft-driven visibility, on the same state `sendButton.visibility` reads.
4053
+ * Hidden removes the control from layout and tab order. Combined with
4054
+ * `visible`: both must pass.
4055
+ * @default "always"
4056
+ */
4057
+ visibility?: ComposerActionVisibility;
4025
4058
  disabled?: boolean | ((state: Readonly<ComposerState>) => boolean);
4026
4059
  /** Disable while a response streams, through the composer's own mechanism. */
4027
4060
  disableWhenStreaming?: boolean;
@@ -4180,6 +4213,16 @@ export type ComposerMode = {
4180
4213
  */
4181
4214
  export type ComposerModeGroupPresentation = "buttons" | "segmented";
4182
4215
 
4216
+ /**
4217
+ * Whether a group's active modes also render as removable chips.
4218
+ *
4219
+ * - `"auto"` (default): one chip per active mode, except under `"segmented"`
4220
+ * presentation, where the track already shows the state.
4221
+ * - `"hidden"`: no chips for this group under any presentation. The pressed bar
4222
+ * button carries the state, and deselection stays on the button.
4223
+ */
4224
+ export type ComposerModeChipVisibility = "auto" | "hidden";
4225
+
4183
4226
  /**
4184
4227
  * Selection policy for a set of modes. `"single"` deselects siblings when one
4185
4228
  * is chosen; `"multiple"` toggles independently.
@@ -4189,6 +4232,12 @@ export type ComposerModeGroup = {
4189
4232
  selection: "single" | "multiple";
4190
4233
  /** @default "buttons" */
4191
4234
  presentation?: ComposerModeGroupPresentation;
4235
+ /**
4236
+ * Chip row policy for this group's active modes. The chip row itself is
4237
+ * derived from its children, so a group with no other chips leaves no gap.
4238
+ * @default "auto"
4239
+ */
4240
+ chipVisibility?: ComposerModeChipVisibility;
4192
4241
  /**
4193
4242
  * Accessible name for a segmented track. With it the track is a labelled
4194
4243
  * `role="group"`; without it the segments stand on their own names.
@@ -5641,6 +5690,8 @@ export type ClientSession = {
5641
5690
  targetId?: string;
5642
5691
  /** Opaque change token; differs whenever the conversation transcript mutated. */
5643
5692
  conversationRevision?: string;
5693
+ /** Server-negotiated durable reconnect support for this client-token session. */
5694
+ durableRecovery?: { enabled: boolean };
5644
5695
  /** Visitor grant backing history. `token` appears only at mint. */
5645
5696
  visitor?: ClientVisitorGrant;
5646
5697
  /** Configuration from the server */
@@ -5681,6 +5732,8 @@ export type ClientInitResponse = {
5681
5732
  conversationId?: string;
5682
5733
  targetId?: string;
5683
5734
  conversationRevision?: string;
5735
+ /** Present on servers that understand the durable-recovery negotiation. */
5736
+ durableRecovery?: { enabled: boolean };
5684
5737
  visitor?: ClientVisitorGrant;
5685
5738
  config: {
5686
5739
  welcomeMessage: string | null;
@@ -5772,6 +5825,16 @@ export interface WidgetHistoryInternals {
5772
5825
  /** Persist the revision beside the active conversation id (internal only). */
5773
5826
  setStoredConversationRevision?: (revision: string | null) => void;
5774
5827
  getStoredConversationRevision?: () => string | null;
5828
+ /** Internal client-token durable resume handle, persisted with widget state. */
5829
+ setStoredResumableHandle?: (
5830
+ handle: { executionId: string; after: string } | null
5831
+ ) => void;
5832
+ getStoredResumableHandle?: () => {
5833
+ executionId: string;
5834
+ after: string;
5835
+ } | null;
5836
+ /** True only while Persona owns a persisted client-token recovery attempt. */
5837
+ shouldResumeDurableConversation?: () => boolean;
5775
5838
  /** "Show earlier messages" cursor for the persisted transcript. */
5776
5839
  setStoredMessageCursor?: (cursor: string | null) => void;
5777
5840
  getStoredMessageCursor?: () => string | null;
@@ -0,0 +1,202 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+ import type { ComposerAction } from "./types";
7
+
8
+ const mounts: HTMLElement[] = [];
9
+ const controllers: ReturnType<typeof createAgentExperience>[] = [];
10
+
11
+ const makeController = (config: Record<string, unknown> = {}) => {
12
+ const mount = document.createElement("div");
13
+ document.body.appendChild(mount);
14
+ mounts.push(mount);
15
+ const controller = createAgentExperience(mount, {
16
+ apiUrl: "https://api.example.com/chat",
17
+ launcher: { enabled: false },
18
+ persistState: false,
19
+ suggestionChips: [],
20
+ ...config,
21
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
22
+ controllers.push(controller);
23
+ return { mount, controller };
24
+ };
25
+
26
+ /** The store coalesces on a microtask, so state-driven syncs need a turn. */
27
+ const flush = async (times = 4) => {
28
+ for (let i = 0; i < times; i += 1) {
29
+ // eslint-disable-next-line no-await-in-loop
30
+ await Promise.resolve();
31
+ }
32
+ };
33
+
34
+ const actionEl = (mount: HTMLElement, id: string) =>
35
+ mount.querySelector<HTMLElement>(`[data-persona-composer-action="${id}"]`);
36
+
37
+ const typeInto = (mount: HTMLElement, value: string): void => {
38
+ const textarea = mount.querySelector<HTMLTextAreaElement>(
39
+ "[data-persona-composer-input]"
40
+ )!;
41
+ textarea.value = value;
42
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
43
+ };
44
+
45
+ const voiceAction = (patch: Partial<ComposerAction> = {}): ComposerAction =>
46
+ ({
47
+ id: "voice-mode",
48
+ placement: "end",
49
+ label: "Voice mode",
50
+ iconName: "mic",
51
+ onSelect: () => {},
52
+ ...patch,
53
+ }) as ComposerAction;
54
+
55
+ describe("ComposerAction.visibility", () => {
56
+ beforeEach(() => {
57
+ window.scrollTo = vi.fn();
58
+ });
59
+
60
+ afterEach(() => {
61
+ controllers.splice(0).forEach((controller) => {
62
+ try {
63
+ controller.destroy();
64
+ } catch {
65
+ /* already destroyed in the test */
66
+ }
67
+ });
68
+ mounts.splice(0).forEach((mount) => mount.remove());
69
+ document.body.innerHTML = "";
70
+ window.localStorage.clear();
71
+ vi.restoreAllMocks();
72
+ });
73
+
74
+ it("mounts the control on every draft state under the default", async () => {
75
+ const { mount } = makeController({
76
+ composer: { actions: [voiceAction()] },
77
+ });
78
+
79
+ expect(actionEl(mount, "voice-mode")).toBeTruthy();
80
+ typeInto(mount, "Hello");
81
+ await flush();
82
+ expect(actionEl(mount, "voice-mode")).toBeTruthy();
83
+ });
84
+
85
+ it('"when-empty" drops the control out of the DOM once the draft has text', async () => {
86
+ const { mount } = makeController({
87
+ composer: { actions: [voiceAction({ visibility: "when-empty" })] },
88
+ });
89
+
90
+ await flush();
91
+ expect(actionEl(mount, "voice-mode")).toBeTruthy();
92
+
93
+ typeInto(mount, "Hello");
94
+ await flush();
95
+ // Removed, not faded: gone from layout and from tab order.
96
+ expect(actionEl(mount, "voice-mode")).toBeNull();
97
+
98
+ // Whitespace is not a draft.
99
+ typeInto(mount, " ");
100
+ await flush();
101
+ expect(actionEl(mount, "voice-mode")).toBeTruthy();
102
+ });
103
+
104
+ it('"when-text" is the inverse of "when-empty" on the same state', async () => {
105
+ const { mount } = makeController({
106
+ composer: {
107
+ actions: [
108
+ voiceAction({ visibility: "when-empty" }),
109
+ voiceAction({ id: "improve", visibility: "when-text", label: "Improve" }),
110
+ ],
111
+ },
112
+ });
113
+
114
+ await flush();
115
+ expect(actionEl(mount, "voice-mode")).toBeTruthy();
116
+ expect(actionEl(mount, "improve")).toBeNull();
117
+
118
+ typeInto(mount, "Hello");
119
+ await flush();
120
+ expect(actionEl(mount, "voice-mode")).toBeNull();
121
+ expect(actionEl(mount, "improve")).toBeTruthy();
122
+ });
123
+
124
+ it('hides a "when-empty" control while streaming, and holds it across chunks', async () => {
125
+ const chunks = [
126
+ 'data: {"type":"text_delta","delta":"one"}\n\n',
127
+ 'data: {"type":"text_delta","delta":" two"}\n\n',
128
+ ];
129
+ let release: () => void = () => {};
130
+ const settled = new Promise<void>((resolve) => {
131
+ release = resolve;
132
+ });
133
+ const encoder = new TextEncoder();
134
+ global.fetch = vi.fn().mockImplementation(
135
+ async () =>
136
+ ({
137
+ ok: true,
138
+ headers: new Headers({ "content-type": "text/event-stream" }),
139
+ body: new ReadableStream({
140
+ async start(controller) {
141
+ for (const chunk of chunks) {
142
+ controller.enqueue(encoder.encode(chunk));
143
+ // Each chunk is its own turn, so the action row re-syncs between
144
+ // them: a post-render patch would be visible here.
145
+ await Promise.resolve();
146
+ }
147
+ await settled;
148
+ controller.close();
149
+ },
150
+ }),
151
+ }) as unknown as Response
152
+ );
153
+
154
+ const { mount } = makeController({
155
+ composer: { actions: [voiceAction({ visibility: "when-empty" })] },
156
+ });
157
+
158
+ typeInto(mount, "Hello");
159
+ await flush();
160
+ mount
161
+ .querySelector<HTMLButtonElement>("[data-persona-composer-submit]")!
162
+ .click();
163
+
164
+ // The draft cleared on submit; the stream is what keeps the control hidden.
165
+ for (let i = 0; i < 12; i += 1) {
166
+ // eslint-disable-next-line no-await-in-loop
167
+ await flush();
168
+ expect(actionEl(mount, "voice-mode")).toBeNull();
169
+ }
170
+
171
+ release();
172
+ await flush(20);
173
+ expect(actionEl(mount, "voice-mode")).toBeTruthy();
174
+ });
175
+
176
+ it("stays hidden when the visible predicate says so, whatever the draft", async () => {
177
+ const { mount } = makeController({
178
+ composer: {
179
+ actions: [voiceAction({ visibility: "when-empty", visible: false })],
180
+ },
181
+ });
182
+
183
+ await flush();
184
+ expect(actionEl(mount, "voice-mode")).toBeNull();
185
+ });
186
+
187
+ it("follows a live update() of the visibility mode", async () => {
188
+ const { mount, controller } = makeController({
189
+ composer: { actions: [voiceAction()] },
190
+ });
191
+
192
+ typeInto(mount, "Hello");
193
+ await flush();
194
+ expect(actionEl(mount, "voice-mode")).toBeTruthy();
195
+
196
+ controller.update({
197
+ composer: { actions: [voiceAction({ visibility: "when-empty" })] },
198
+ });
199
+ await flush();
200
+ expect(actionEl(mount, "voice-mode")).toBeNull();
201
+ });
202
+ });
@@ -335,6 +335,43 @@ describe("composer model picker", () => {
335
335
  );
336
336
  });
337
337
 
338
+ it("returns the menu to the stylesheet fallback when an update unsets the token", () => {
339
+ const token = "--persona-components-composer-modelPicker-menuBackground";
340
+ const { mount, controller } = makeController({
341
+ ...popoverConfig(),
342
+ theme: {
343
+ components: { composer: { modelPicker: { menuBackground: "#1e1f20" } } },
344
+ },
345
+ });
346
+ expect(mount.style.getPropertyValue(token)).toBe("#1e1f20");
347
+ // The panel is portaled to document.body, so it never inherits the
348
+ // mount's theme vars. Stamped on the trigger here because jsdom's
349
+ // computed style does not inherit custom properties.
350
+ const button = trigger(mount)!;
351
+ button.style.setProperty(token, "#1e1f20");
352
+
353
+ button.click();
354
+ const panel = menu()!;
355
+ expect(panel.style.getPropertyValue(token)).toBe("#1e1f20");
356
+ button.click();
357
+
358
+ // Explicit undefined is the documented reset, so the mount stops
359
+ // carrying the var; the trigger's stand-in follows it.
360
+ controller.update({
361
+ theme: {
362
+ components: { composer: { modelPicker: { menuBackground: undefined } } },
363
+ },
364
+ });
365
+ expect(mount.style.getPropertyValue(token)).toBe("");
366
+ button.style.removeProperty(token);
367
+
368
+ // The menu outlives the update, so the next open has to clear the value
369
+ // the first one stamped; an empty inline value is the fallback state.
370
+ trigger(mount)!.click();
371
+ expect(menu()).toBe(panel);
372
+ expect(panel.style.getPropertyValue(token)).toBe("");
373
+ });
374
+
338
375
  it("shows the selected label on the closed control", () => {
339
376
  const { mount } = makeController(
340
377
  popoverConfig({ selectedModelId: "smart" })
@@ -326,3 +326,85 @@ describe("composer modes", () => {
326
326
  expect(panel.querySelector('[role="menuitem"]')?.textContent).toContain("Hidden");
327
327
  });
328
328
  });
329
+
330
+ describe("modeGroups chipVisibility", () => {
331
+ beforeEach(() => {
332
+ window.scrollTo = vi.fn();
333
+ });
334
+
335
+ afterEach(() => {
336
+ controllers.splice(0).forEach((controller) => {
337
+ try {
338
+ controller.destroy();
339
+ } catch {
340
+ /* already destroyed */
341
+ }
342
+ });
343
+ mounts.splice(0).forEach((mount) => mount.remove());
344
+ document.body.innerHTML = "";
345
+ window.localStorage.clear();
346
+ vi.restoreAllMocks();
347
+ });
348
+
349
+ const hiddenGroups: ComposerModeGroup[] = [
350
+ { id: "tool", selection: "single", chipVisibility: "hidden" },
351
+ { id: "style", selection: "multiple" },
352
+ ];
353
+
354
+ it("keeps the bar button but renders no chip for the opted-out group", async () => {
355
+ const { mount, controller } = makeController({
356
+ composer: { modes, modeGroups: hiddenGroups },
357
+ });
358
+
359
+ modeButton(mount, "search").click();
360
+ await flush();
361
+ expect(controller.getComposerState().activeModeIds).toEqual(["search"]);
362
+ expect(modeButton(mount, "search").getAttribute("aria-pressed")).toBe("true");
363
+ expect(chips(mount)).toHaveLength(0);
364
+ });
365
+
366
+ it("still chips a group that did not opt out", async () => {
367
+ const { mount } = makeController({
368
+ composer: { modes, modeGroups: hiddenGroups },
369
+ });
370
+
371
+ modeButton(mount, "search").click();
372
+ modeButton(mount, "concise").click();
373
+ await flush();
374
+ expect(
375
+ chips(mount).map((chip) => chip.getAttribute("data-persona-composer-mode"))
376
+ ).toEqual(["concise"]);
377
+ });
378
+
379
+ it("leaves no chip row gap when every active mode is suppressed", async () => {
380
+ const { mount } = makeController({
381
+ composer: {
382
+ modes,
383
+ modeGroups: hiddenGroups,
384
+ defaultActiveModeIds: ["search"],
385
+ },
386
+ });
387
+ await flush();
388
+ const row = chipRow(mount);
389
+ expect(row === null || row.style.display === "none").toBe(true);
390
+ });
391
+
392
+ it("follows a live update() that opts the group back in", async () => {
393
+ const { mount, controller } = makeController({
394
+ composer: {
395
+ modes,
396
+ modeGroups: hiddenGroups,
397
+ defaultActiveModeIds: ["search"],
398
+ },
399
+ });
400
+ await flush();
401
+ expect(chips(mount)).toHaveLength(0);
402
+
403
+ controller.update({ composer: { modes, modeGroups } });
404
+ await flush();
405
+ expect(
406
+ chips(mount).map((chip) => chip.getAttribute("data-persona-composer-mode"))
407
+ ).toEqual(["search"]);
408
+ expect(chipRow(mount)!.style.display).toBe("flex");
409
+ });
410
+ });
@@ -103,6 +103,84 @@ describe("composer overflow menu in the live widget", () => {
103
103
  vi.restoreAllMocks();
104
104
  });
105
105
 
106
+ it("forwards the panel surface tokens onto the portaled menu on open", () => {
107
+ const { mount } = makeController({
108
+ composer: {
109
+ actions: [action("folded", { presentation: "overflow" })],
110
+ actionOverflow: { enabled: true },
111
+ },
112
+ });
113
+ // The panel is portaled to document.body, so it never inherits the mount's
114
+ // theme vars. Stamped on the trigger here because jsdom's computed style
115
+ // does not inherit custom properties.
116
+ const trigger = triggerOf(mount)!;
117
+ trigger.style.setProperty(
118
+ "--persona-components-composer-overflowMenu-background",
119
+ "#353535"
120
+ );
121
+ trigger.style.setProperty(
122
+ "--persona-components-composer-overflowMenu-borderColor",
123
+ "rgba(255, 255, 255, 0.08)"
124
+ );
125
+
126
+ const panel = openPanel(mount)!;
127
+
128
+ expect(
129
+ panel.style.getPropertyValue(
130
+ "--persona-components-composer-overflowMenu-background"
131
+ )
132
+ ).toBe("#353535");
133
+ expect(
134
+ panel.style.getPropertyValue(
135
+ "--persona-components-composer-overflowMenu-borderColor"
136
+ )
137
+ ).toBe("rgba(255, 255, 255, 0.08)");
138
+ // Unset keys stamp nothing, so the stylesheet's fallback still stands.
139
+ expect(
140
+ panel.style.getPropertyValue(
141
+ "--persona-components-composer-overflowMenu-shadow"
142
+ )
143
+ ).toBe("");
144
+ });
145
+
146
+ it("returns the panel to the stylesheet fallback when an update unsets the token", () => {
147
+ const token = "--persona-components-composer-overflowMenu-background";
148
+ const { mount, controller } = makeController({
149
+ composer: {
150
+ actions: [action("folded", { presentation: "overflow" })],
151
+ actionOverflow: { enabled: true },
152
+ },
153
+ theme: {
154
+ components: { composer: { overflowMenu: { background: "#353535" } } },
155
+ },
156
+ });
157
+ expect(mount.style.getPropertyValue(token)).toBe("#353535");
158
+ // Stamped on the trigger for the same reason as the test above: jsdom's
159
+ // computed style does not inherit custom properties.
160
+ const trigger = triggerOf(mount)!;
161
+ trigger.style.setProperty(token, "#353535");
162
+
163
+ const panel = openPanel(mount)!;
164
+ expect(panel.style.getPropertyValue(token)).toBe("#353535");
165
+ triggerOf(mount)!.click();
166
+
167
+ // Explicit undefined is the documented reset, so the mount stops carrying
168
+ // the var; the trigger's stand-in follows it.
169
+ controller.update({
170
+ theme: {
171
+ components: { composer: { overflowMenu: { background: undefined } } },
172
+ },
173
+ });
174
+ expect(mount.style.getPropertyValue(token)).toBe("");
175
+ trigger.style.removeProperty(token);
176
+
177
+ // The panel outlives the update, so the next open has to clear the value
178
+ // the first one stamped; an empty inline value is the fallback state.
179
+ const reopened = openPanel(mount)!;
180
+ expect(reopened).toBe(panel);
181
+ expect(reopened.style.getPropertyValue(token)).toBe("");
182
+ });
183
+
106
184
  it("sorts the trigger at the 900 anchor by default", () => {
107
185
  const { mount } = makeController({
108
186
  composer: {
@@ -212,7 +212,7 @@ describe("history controller lifecycle", () => {
212
212
  expect(window.localStorage.getItem(keysA.storageKey)).toBe("cvt_alpha");
213
213
  });
214
214
 
215
- it("drops the store entirely when history is disabled", async () => {
215
+ it("keeps the recovery credential store when history UI is disabled", async () => {
216
216
  await seed();
217
217
  const { controller } = mount({
218
218
  clientToken: TOKEN_A,
@@ -224,7 +224,7 @@ describe("history controller lifecycle", () => {
224
224
  await flush(20);
225
225
 
226
226
  expect(recorder.instances.length).toBe(1);
227
- expect(recorder.instances[0]!.destroy).toHaveBeenCalledTimes(1);
227
+ expect(recorder.instances[0]!.destroy).not.toHaveBeenCalled();
228
228
  expect(recorder.instances[0]!.clear).not.toHaveBeenCalled();
229
229
  });
230
230
  });