@runtypelabs/persona 4.17.1 → 4.18.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 (158) hide show
  1. package/dist/chunk-3I2S3I5P.js +1 -0
  2. package/dist/chunk-76IELU7F.js +1 -0
  3. package/dist/chunk-EFJBUH5F.js +1 -0
  4. package/dist/chunk-PDPVORUF.js +1 -0
  5. package/dist/chunk-Q735PSZL.js +1 -0
  6. package/dist/chunk-VK6JOIYJ.js +1 -0
  7. package/dist/codegen.cjs +4 -4
  8. package/dist/codegen.js +4 -4
  9. package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
  10. package/dist/context-mentions.cjs +9 -8
  11. package/dist/context-mentions.d.cts +1212 -14
  12. package/dist/context-mentions.d.ts +1212 -14
  13. package/dist/context-mentions.js +9 -8
  14. package/dist/event-stream-view-4EZ7BGFK.js +1 -0
  15. package/dist/event-stream-view.cjs +129 -0
  16. package/dist/event-stream-view.d.cts +8384 -0
  17. package/dist/event-stream-view.d.ts +8384 -0
  18. package/dist/event-stream-view.js +129 -0
  19. package/dist/history-view-BD6OXLNA.js +898 -0
  20. package/dist/history-view.cjs +898 -0
  21. package/dist/history-view.d.cts +1461 -0
  22. package/dist/history-view.d.ts +1461 -0
  23. package/dist/history-view.js +898 -0
  24. package/dist/index.cjs +77 -77
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +1987 -17
  27. package/dist/index.d.ts +1987 -17
  28. package/dist/index.global.js +67 -66
  29. package/dist/index.global.js.map +1 -1
  30. package/dist/index.js +77 -77
  31. package/dist/index.js.map +1 -1
  32. package/dist/launcher.global.js +5 -4
  33. package/dist/launcher.global.js.map +1 -1
  34. package/dist/plugin-kit.cjs +1 -1
  35. package/dist/plugin-kit.d.cts +0 -8
  36. package/dist/plugin-kit.d.ts +0 -8
  37. package/dist/plugin-kit.js +1 -1
  38. package/dist/smart-dom-reader.d.cts +1212 -14
  39. package/dist/smart-dom-reader.d.ts +1212 -14
  40. package/dist/theme-editor-preview.cjs +970 -73
  41. package/dist/theme-editor-preview.d.cts +1331 -15
  42. package/dist/theme-editor-preview.d.ts +1331 -15
  43. package/dist/theme-editor-preview.js +81 -81
  44. package/dist/theme-editor.cjs +7 -7
  45. package/dist/theme-editor.d.cts +1215 -15
  46. package/dist/theme-editor.d.ts +1215 -15
  47. package/dist/theme-editor.js +9 -9
  48. package/dist/theme-reference.cjs +1 -1
  49. package/dist/theme-reference.d.cts +226 -7
  50. package/dist/theme-reference.d.ts +226 -7
  51. package/dist/theme-reference.js +1 -1
  52. package/dist/widget.css +1 -1
  53. package/package.json +16 -4
  54. package/src/client-history-rest.test.ts +1034 -0
  55. package/src/client-visitor-history.test.ts +596 -0
  56. package/src/client.ts +1040 -24
  57. package/src/components/artifact-inline.ts +8 -1
  58. package/src/components/artifact-pane.test.ts +278 -1
  59. package/src/components/artifact-pane.ts +135 -67
  60. package/src/components/artifact-preview.test.ts +14 -1
  61. package/src/components/artifact-preview.ts +9 -0
  62. package/src/components/header-builder.ts +3 -0
  63. package/src/components/header-layouts.test.ts +171 -7
  64. package/src/components/header-layouts.ts +83 -53
  65. package/src/components/header-parts.test.ts +154 -1
  66. package/src/components/header-parts.ts +266 -138
  67. package/src/components/history-confirm-dialog.ts +177 -0
  68. package/src/components/history-shell-copy.ts +99 -0
  69. package/src/components/history-view/announcer.ts +40 -0
  70. package/src/components/history-view/copy.ts +105 -0
  71. package/src/components/history-view/css.ts +1078 -0
  72. package/src/components/history-view/grouping.test.ts +142 -0
  73. package/src/components/history-view/grouping.ts +142 -0
  74. package/src/components/history-view/icons.ts +73 -0
  75. package/src/components/history-view/rows.ts +341 -0
  76. package/src/components/history-view/state.ts +309 -0
  77. package/src/components/history-view.test.ts +2660 -0
  78. package/src/components/history-view.ts +1891 -0
  79. package/src/components/launcher-surface.test.ts +133 -0
  80. package/src/components/launcher.test.ts +18 -0
  81. package/src/components/launcher.ts +7 -7
  82. package/src/components/panel.ts +17 -7
  83. package/src/components/welcome.ts +72 -2
  84. package/src/defaults.ts +36 -3
  85. package/src/event-stream-view-bundle.test.ts +69 -0
  86. package/src/event-stream-view-entry.ts +19 -0
  87. package/src/event-stream-view-loader.ts +31 -0
  88. package/src/event-stream-view.ts +19 -0
  89. package/src/generated/runtype-openapi-contract.ts +1 -0
  90. package/src/history-render.ts +325 -0
  91. package/src/history-view-bundle.test.ts +78 -0
  92. package/src/history-view-entry.ts +41 -0
  93. package/src/history-view-loader.test.ts +30 -0
  94. package/src/history-view-loader.ts +32 -0
  95. package/src/history-view.ts +28 -0
  96. package/src/index-core.ts +50 -2
  97. package/src/index-global.ts +55 -0
  98. package/src/internal/demo-history-provider.test.ts +402 -0
  99. package/src/internal/demo-history-provider.ts +689 -0
  100. package/src/internal/history-provider-registry.ts +25 -0
  101. package/src/internal/history-provider.ts +150 -0
  102. package/src/internal/runtype-history-provider.test.ts +313 -0
  103. package/src/internal/runtype-history-provider.ts +240 -0
  104. package/src/plugin-kit.test.ts +16 -0
  105. package/src/plugin-kit.ts +13 -5
  106. package/src/plugins/types.ts +177 -1
  107. package/src/session-history.test.ts +1397 -0
  108. package/src/session.ts +1200 -26
  109. package/src/styles/widget-styles.test.ts +133 -0
  110. package/src/styles/widget.css +227 -8
  111. package/src/theme-editor/preview-utils.test.ts +19 -0
  112. package/src/theme-editor/preview-utils.ts +15 -1
  113. package/src/theme-editor/preview.ts +7 -1
  114. package/src/theme-editor/role-mappings.ts +4 -1
  115. package/src/theme-editor/sections.test.ts +168 -1
  116. package/src/theme-editor/sections.ts +136 -7
  117. package/src/theme-editor/webmcp/summary.ts +16 -2
  118. package/src/theme-reference.ts +20 -6
  119. package/src/types/theme.ts +213 -7
  120. package/src/types.ts +927 -7
  121. package/src/ui.artifact-rail-resize.test.ts +235 -0
  122. package/src/ui.attachments-drop.test.ts +1 -0
  123. package/src/ui.header-update-stability.test.ts +2 -2
  124. package/src/ui.history-chunk.test.ts +72 -0
  125. package/src/ui.history-config-provider.test.ts +318 -0
  126. package/src/ui.history-controller.test.ts +711 -0
  127. package/src/ui.history-lifecycle.test.ts +456 -0
  128. package/src/ui.history-render-hooks.test.ts +904 -0
  129. package/src/ui.history-shell.test.ts +1887 -0
  130. package/src/ui.rail-overlay.test.ts +573 -0
  131. package/src/ui.rail-resize.test.ts +284 -0
  132. package/src/ui.rebuild-composer.test.ts +21 -0
  133. package/src/ui.render-welcome.test.ts +42 -0
  134. package/src/ui.shortcuts.test.ts +166 -0
  135. package/src/ui.ts +3071 -240
  136. package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
  137. package/src/utils/artifact-file.ts +15 -0
  138. package/src/utils/artifact-gate.test.ts +4 -0
  139. package/src/utils/artifact-gate.ts +13 -17
  140. package/src/utils/artifact-resize.test.ts +28 -0
  141. package/src/utils/artifact-resize.ts +9 -2
  142. package/src/utils/buttons.ts +188 -1
  143. package/src/utils/config-merge.test.ts +91 -0
  144. package/src/utils/dropdown.ts +10 -1
  145. package/src/utils/history-messages.test.ts +284 -0
  146. package/src/utils/history-messages.ts +284 -0
  147. package/src/utils/icons.ts +3 -0
  148. package/src/utils/shortcuts.test.ts +288 -0
  149. package/src/utils/shortcuts.ts +221 -0
  150. package/src/utils/theme.test.ts +501 -4
  151. package/src/utils/theme.ts +42 -3
  152. package/src/utils/tokens.ts +153 -15
  153. package/src/utils/tooltip-timing.ts +4 -0
  154. package/src/utils/tooltip.test.ts +249 -7
  155. package/src/utils/tooltip.ts +146 -4
  156. package/src/utils/visitor-store.test.ts +425 -0
  157. package/src/utils/visitor-store.ts +337 -0
  158. package/dist/chunk-QJ4LV2KW.js +0 -1
@@ -0,0 +1,2660 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createHistoryView, type HistoryViewOptions } from "./history-view";
6
+ import {
7
+ createDemoHistoryProvider,
8
+ type DemoHistoryConversationSeed,
9
+ type DemoHistoryProvider,
10
+ } from "../internal/demo-history-provider";
11
+ import {
12
+ HistoryProviderError,
13
+ type HistoryProvider,
14
+ } from "../internal/history-provider";
15
+
16
+ const NOW = new Date(2026, 2, 15, 12, 0, 0).getTime();
17
+ const MIN = 60_000;
18
+ const HOUR = 60 * MIN;
19
+ const DAY = 24 * HOUR;
20
+
21
+ const at = (offsetMs: number): string => new Date(NOW - offsetMs).toISOString();
22
+
23
+ const seed = (
24
+ id: string,
25
+ offsetMs: number,
26
+ overrides: Partial<DemoHistoryConversationSeed> = {}
27
+ ): DemoHistoryConversationSeed => ({
28
+ id,
29
+ title: `Session ${id}`,
30
+ targetId: "t1",
31
+ preview: `Preview for ${id}`,
32
+ createdAt: at(offsetMs + MIN),
33
+ updatedAt: at(offsetMs),
34
+ messages: [
35
+ { role: "user", content: `Question ${id}`, createdAt: at(offsetMs + MIN) },
36
+ { role: "assistant", content: `Answer ${id}`, createdAt: at(offsetMs) },
37
+ ],
38
+ ...overrides,
39
+ });
40
+
41
+ const DEFAULT_SEEDS = [
42
+ seed("a", 30 * MIN),
43
+ seed("b", 2 * HOUR),
44
+ seed("c", 26 * HOUR),
45
+ seed("d", 4 * DAY),
46
+ ];
47
+
48
+ const flush = async (): Promise<void> => {
49
+ await new Promise((resolve) => setTimeout(resolve, 0));
50
+ await new Promise((resolve) => setTimeout(resolve, 0));
51
+ };
52
+
53
+ type Mounted = {
54
+ provider: DemoHistoryProvider;
55
+ handle: ReturnType<typeof createHistoryView>;
56
+ root: HTMLElement;
57
+ onSelect: ReturnType<typeof vi.fn>;
58
+ onStartNew: ReturnType<typeof vi.fn>;
59
+ onClose: ReturnType<typeof vi.fn>;
60
+ onRequestDeleteConversation: ReturnType<typeof vi.fn>;
61
+ onRequestClearHistory: ReturnType<typeof vi.fn>;
62
+ onRequestResetIdentity: ReturnType<typeof vi.fn>;
63
+ };
64
+
65
+ const mounted: Mounted[] = [];
66
+
67
+ function mount(
68
+ overrides: Partial<HistoryViewOptions> & {
69
+ seeds?: DemoHistoryConversationSeed[];
70
+ pageSize?: number;
71
+ provider?: HistoryProvider;
72
+ } = {}
73
+ ): Mounted {
74
+ const provider =
75
+ (overrides.provider as DemoHistoryProvider | undefined) ??
76
+ createDemoHistoryProvider({
77
+ conversations: overrides.seeds ?? DEFAULT_SEEDS,
78
+ pageSize: overrides.pageSize ?? 25,
79
+ now: () => NOW,
80
+ });
81
+
82
+ const onSelect = vi.fn(async () => {});
83
+ const onStartNew = vi.fn(async () => {});
84
+ const onClose = vi.fn();
85
+ const onRequestDeleteConversation = vi.fn(async () => "deleted" as const);
86
+ const onRequestClearHistory = vi.fn(async () => "cleared" as const);
87
+ const onRequestResetIdentity = vi.fn(async () => ({
88
+ outcome: "reset" as const,
89
+ remoteRevocationConfirmed: true,
90
+ }));
91
+
92
+ const handle = createHistoryView({
93
+ provider,
94
+ context: { scope: "browser" },
95
+ targetId: "t1",
96
+ presentation: "panel",
97
+ showScopeStatus: true,
98
+ activeConversationId: null,
99
+ pageSize: overrides.pageSize ?? 25,
100
+ now: () => NOW,
101
+ onSelect,
102
+ onStartNew,
103
+ onClose,
104
+ onRequestDeleteConversation,
105
+ onRequestClearHistory,
106
+ onRequestResetIdentity,
107
+ ...overrides,
108
+ });
109
+
110
+ document.body.appendChild(handle.element);
111
+ const record: Mounted = {
112
+ provider,
113
+ handle,
114
+ root: handle.element,
115
+ onSelect,
116
+ onStartNew,
117
+ onClose,
118
+ onRequestDeleteConversation,
119
+ onRequestClearHistory,
120
+ onRequestResetIdentity,
121
+ };
122
+ mounted.push(record);
123
+ return record;
124
+ }
125
+
126
+ const rows = (root: HTMLElement): HTMLButtonElement[] =>
127
+ Array.from(root.querySelectorAll<HTMLButtonElement>(".persona-history-row"));
128
+
129
+ const rowFor = (root: HTMLElement, id: string): HTMLButtonElement => {
130
+ const row = root.querySelector<HTMLButtonElement>(
131
+ `[data-persona-history-conversation="${id}"]`
132
+ );
133
+ if (!row) throw new Error(`row ${id} not rendered`);
134
+ return row;
135
+ };
136
+
137
+ const menuButtonFor = (root: HTMLElement, id: string): HTMLButtonElement => {
138
+ const button = root.querySelector<HTMLButtonElement>(
139
+ `[data-persona-history-focus="menu:${id}"]`
140
+ );
141
+ if (!button) throw new Error(`menu trigger ${id} not rendered`);
142
+ return button;
143
+ };
144
+
145
+ const stateBlock = (root: HTMLElement): HTMLElement | null =>
146
+ root.querySelector<HTMLElement>("[data-persona-history-state]");
147
+
148
+ const listOptions = (root: HTMLElement): HTMLButtonElement | null =>
149
+ root.querySelector<HTMLButtonElement>(".persona-history-list-options");
150
+
151
+ /** Opens the list overflow menu and returns its items in DOM order. */
152
+ const openListMenu = (root: HTMLElement): HTMLButtonElement[] => {
153
+ const trigger = listOptions(root);
154
+ if (!trigger) throw new Error("list options trigger not rendered");
155
+ trigger.click();
156
+ return Array.from(
157
+ root.querySelectorAll<HTMLButtonElement>(
158
+ '.persona-history-caption [role="menuitem"]'
159
+ )
160
+ );
161
+ };
162
+
163
+ const injectedHistoryCss = (): string =>
164
+ document.querySelector(
165
+ 'style[data-persona-plugin-style="persona-history-view"]'
166
+ )?.textContent ?? "";
167
+
168
+ const liveText = (root: HTMLElement): string =>
169
+ root.querySelector("[data-persona-history-live-region]")?.textContent ?? "";
170
+
171
+ afterEach(() => {
172
+ while (mounted.length > 0) mounted.pop()?.handle.destroy();
173
+ document.body.replaceChildren();
174
+ });
175
+
176
+ describe("history view rows", () => {
177
+ it("renders rows in server order with the title over the preview", async () => {
178
+ const { root } = mount();
179
+ await flush();
180
+
181
+ const rendered = rows(root);
182
+ expect(rendered).toHaveLength(4);
183
+ expect(
184
+ rendered.map((row) => row.getAttribute("data-persona-history-conversation"))
185
+ ).toEqual(["a", "b", "c", "d"]);
186
+
187
+ const first = rendered[0];
188
+ const title = first.querySelector(".persona-history-row-title");
189
+ const preview = first.querySelector(".persona-history-row-preview");
190
+ const time = first.querySelector("time");
191
+
192
+ expect(title?.textContent).toBe("Session a");
193
+ expect(title?.classList.contains("persona-history-truncate")).toBe(true);
194
+ expect(preview?.textContent).toBe("Preview for a");
195
+ // Both lines are single-line and truncate; nothing wraps to a second line.
196
+ expect(preview?.classList.contains("persona-history-truncate")).toBe(true);
197
+ // Time is aligned to the title edge (same head row); preview follows below.
198
+ expect(time?.parentElement?.classList.contains("persona-history-row-head")).toBe(
199
+ true
200
+ );
201
+ expect(time?.getAttribute("datetime")).toBe(at(30 * MIN));
202
+ expect(time?.textContent).toBe("30m");
203
+ expect(title?.compareDocumentPosition(preview as Node)).toBe(
204
+ Node.DOCUMENT_POSITION_FOLLOWING
205
+ );
206
+ });
207
+
208
+ it("keeps the message count quiet but available", async () => {
209
+ const { root } = mount();
210
+ await flush();
211
+ const count = rowFor(root, "a").querySelector(".persona-history-row-count");
212
+ expect(count?.textContent).toBe("2 messages");
213
+ expect(count?.classList.contains("persona-history-sr-only")).toBe(true);
214
+ });
215
+
216
+ it("collapses a missing preview instead of reserving a line", async () => {
217
+ const { root } = mount({
218
+ seeds: [seed("a", 30 * MIN, { preview: null }), seed("b", 2 * HOUR)],
219
+ });
220
+ await flush();
221
+ expect(
222
+ rowFor(root, "a").querySelector(".persona-history-row-preview")
223
+ ).toBeNull();
224
+ expect(
225
+ rowFor(root, "b").querySelector(".persona-history-row-preview")
226
+ ).not.toBeNull();
227
+ });
228
+
229
+ it("renders text-only rows when no avatar source is provided", async () => {
230
+ const { root } = mount();
231
+ await flush();
232
+ // No placeholder glyph: rows without a real mark drop the block entirely.
233
+ expect(
234
+ rowFor(root, "a").querySelector(".persona-history-row-avatar")
235
+ ).toBeNull();
236
+ });
237
+
238
+ it("leads the row with the configured glyph avatar", async () => {
239
+ const { root } = mount({ rowAvatar: "🛍️" });
240
+ await flush();
241
+ const avatar = rowFor(root, "a").querySelector<HTMLElement>(
242
+ ".persona-history-row-avatar"
243
+ );
244
+ expect(avatar?.getAttribute("aria-hidden")).toBe("true");
245
+ expect(avatar?.querySelector("img")).toBeNull();
246
+ expect(avatar?.textContent).toBe("🛍️");
247
+ // The avatar leads the text column.
248
+ expect(
249
+ avatar?.compareDocumentPosition(
250
+ rowFor(root, "a").querySelector(".persona-history-row-body") as Node
251
+ )
252
+ ).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
253
+ });
254
+
255
+ it("renders a configured avatar URL as an image and a glyph as text", async () => {
256
+ const { root } = mount({ rowAvatar: "https://example.com/a.png" });
257
+ await flush();
258
+ const image = rowFor(root, "a").querySelector<HTMLImageElement>(
259
+ ".persona-history-row-avatar img"
260
+ );
261
+ expect(image?.getAttribute("src")).toBe("https://example.com/a.png");
262
+ expect(image?.getAttribute("alt")).toBe("");
263
+
264
+ const glyph = mount({ rowAvatar: "🥐" });
265
+ await flush();
266
+ expect(
267
+ rowFor(glyph.root, "a").querySelector(".persona-history-row-avatar")
268
+ ?.textContent
269
+ ).toBe("🥐");
270
+ });
271
+
272
+ it("drops the avatar block entirely when it is disabled", async () => {
273
+ const { root } = mount({ rowAvatar: false });
274
+ await flush();
275
+ expect(root.querySelector(".persona-history-row-avatar")).toBeNull();
276
+ expect(
277
+ rowFor(root, "a").querySelector(".persona-history-row-body")
278
+ ).not.toBeNull();
279
+ });
280
+
281
+ it("promotes the preview when a conversation has no server title", async () => {
282
+ const { root } = mount({
283
+ seeds: [
284
+ seed("a", 30 * MIN, { title: " " }),
285
+ seed("b", 2 * HOUR, { title: "", preview: null }),
286
+ ],
287
+ });
288
+ await flush();
289
+
290
+ const first = rowFor(root, "a");
291
+ expect(first.querySelector(".persona-history-row-title")?.textContent).toBe(
292
+ "Preview for a"
293
+ );
294
+ expect(first.querySelector(".persona-history-row-preview")).toBeNull();
295
+ // Nothing to promote: the title line stays empty rather than inventing text.
296
+ expect(rowFor(root, "b").querySelector(".persona-history-row-title")?.textContent).toBe(
297
+ ""
298
+ );
299
+ });
300
+
301
+ it("marks the active row with aria-current and a class, not color alone", async () => {
302
+ const { root, handle } = mount({ activeConversationId: "b" });
303
+ await flush();
304
+
305
+ expect(rowFor(root, "b").getAttribute("aria-current")).toBe("page");
306
+ expect(rowFor(root, "b").classList.contains("persona-history-row--active")).toBe(
307
+ true
308
+ );
309
+ expect(rowFor(root, "a").hasAttribute("aria-current")).toBe(false);
310
+
311
+ handle.setActiveConversationId("a");
312
+ expect(rowFor(root, "a").getAttribute("aria-current")).toBe("page");
313
+ expect(rowFor(root, "b").hasAttribute("aria-current")).toBe(false);
314
+ });
315
+ });
316
+
317
+ describe("history view list styling", () => {
318
+ it("keeps the row menu trigger a hover-revealed sibling of the row button", async () => {
319
+ const { root } = mount();
320
+ await flush();
321
+ const trigger = menuButtonFor(root, "a");
322
+ expect(trigger.parentElement?.classList.contains("persona-history-item")).toBe(
323
+ true
324
+ );
325
+ expect(trigger.closest(".persona-history-row")).toBeNull();
326
+
327
+ const css = injectedHistoryCss();
328
+ const hover = css.slice(css.indexOf("@media (hover: hover)"));
329
+ expect(hover).toContain(
330
+ ".persona-history-view button.persona-history-row-menu-button {\n opacity: 0;"
331
+ );
332
+ expect(hover).toContain(
333
+ ".persona-history-view li.persona-history-item:hover button.persona-history-row-menu-button"
334
+ );
335
+ expect(hover).toContain(
336
+ ".persona-history-view li.persona-history-item:focus-within button.persona-history-row-menu-button"
337
+ );
338
+ // An open menu never fades out from under the pointer.
339
+ expect(css).toContain(
340
+ '.persona-history-view button.persona-history-row-menu-button[aria-expanded="true"] {\n opacity: 1;\n}'
341
+ );
342
+ });
343
+
344
+ it("keeps the trigger chip opaque and tapers the preview under it", async () => {
345
+ mount();
346
+ await flush();
347
+ const css = injectedHistoryCss();
348
+ // Wash composited twice over the opaque surface: row text never shows
349
+ // through the hovered or menu-open chip.
350
+ const chip = css.slice(
351
+ css.indexOf(
352
+ ".persona-history-view button.persona-history-row-menu-button:hover:not(:disabled)"
353
+ )
354
+ );
355
+ const chipBlock = chip.slice(0, chip.indexOf("}"));
356
+ expect(chipBlock).toContain('button.persona-history-row-menu-button[aria-expanded="true"]');
357
+ expect(chipBlock.match(/var\(--persona-history-row-hover-bg\)/g)).toHaveLength(4);
358
+ expect(chipBlock).toContain("var(--persona-history-surface-bg);");
359
+ // The glyph keys to LIST text tokens, never the header icon token: it
360
+ // rests muted on the surface and darkens to text on the chip.
361
+ expect(chipBlock).toContain("color: var(--persona-text, #111827);");
362
+ const rest = css.slice(
363
+ css.indexOf(".persona-history-view button.persona-history-row-menu-button {")
364
+ );
365
+ expect(rest.slice(0, rest.indexOf("}"))).toContain(
366
+ "color: var(--persona-text-muted, #6b7280);"
367
+ );
368
+ // The preview fades before the trigger zone in every state that shows the
369
+ // trigger: hover reveal, focus, an open menu, and always on coarse pointers.
370
+ expect(css).toContain(
371
+ ".persona-history-view li.persona-history-item:hover .persona-history-row-preview"
372
+ );
373
+ expect(css).toContain(
374
+ '.persona-history-view li.persona-history-item:has(button[aria-expanded="true"]) .persona-history-row-preview'
375
+ );
376
+ // Standard + -webkit- pairs in both the hover-state rule and the coarse
377
+ // always-on rule: 4 mask-image declarations total.
378
+ expect(
379
+ css.match(/mask-image: linear-gradient\(to right, #000 calc\(100% - 84px\), transparent calc\(100% - 36px\)\)/g)
380
+ ).toHaveLength(4);
381
+ // The always-on pair lives in the shared coarse-pointer block.
382
+ const coarse = css.slice(css.indexOf("@media (pointer: coarse)"));
383
+ expect(coarse.slice(0, coarse.indexOf("\n}") + 2)).toContain(
384
+ ".persona-history-view .persona-history-row-preview"
385
+ );
386
+ });
387
+
388
+ it("sizes shell-hosted bar controls from the header tokens", async () => {
389
+ mount();
390
+ await flush();
391
+ const css = injectedHistoryCss();
392
+ expect(css).toContain(
393
+ ".persona-history-topbar--shell button.persona-history-icon-button {\n" +
394
+ " width: var(--persona-header-control-size, 44px);\n" +
395
+ " height: var(--persona-header-control-size, 44px);\n" +
396
+ " min-width: var(--persona-header-control-size, 44px);\n" +
397
+ " min-height: var(--persona-header-control-size, 44px);\n}"
398
+ );
399
+ // The glyph follows the header icon size, and touch keeps the header's
400
+ // own 40px floor rather than a taller bar-only one.
401
+ expect(css).toContain(
402
+ ".persona-history-topbar--shell button.persona-history-icon-button svg {\n width: var(--persona-header-control-icon-size, 20px);"
403
+ );
404
+ const shellCoarse = css.slice(
405
+ css.indexOf("@media (pointer: coarse) {\n .persona-history-topbar--shell")
406
+ );
407
+ expect(shellCoarse.slice(0, shellCoarse.indexOf("}"))).toContain("min-width: 40px;");
408
+ // The grid tracks mirror the control size so the centered title holds.
409
+ expect(css).toContain(
410
+ "grid-template-columns: var(--persona-header-control-size, 44px) minmax(0, 1fr) var(--persona-header-control-size, 44px);"
411
+ );
412
+ });
413
+
414
+ it("separates rows with spacing, never dividers or borders", async () => {
415
+ mount();
416
+ await flush();
417
+ const css = injectedHistoryCss();
418
+ expect(css).not.toContain("li.persona-history-item + li.persona-history-item");
419
+ expect(css).not.toContain("li.persona-history-item::after");
420
+ expect(css).not.toContain("--persona-history-row-divider");
421
+ });
422
+
423
+ it("flattens the panel list and keeps the rail's date headings", async () => {
424
+ const { root } = mount();
425
+ await flush();
426
+ // The headings stay in the DOM: the lists are still labelled by them.
427
+ expect(root.querySelectorAll(".persona-history-group-heading")).toHaveLength(3);
428
+ const css = injectedHistoryCss();
429
+ const clipped = css.slice(
430
+ css.indexOf(".persona-history-view .persona-history-sr-only")
431
+ );
432
+ // Only the panel's headings join the clipped selector list.
433
+ expect(clipped.slice(0, clipped.indexOf("{"))).toContain(
434
+ ".persona-history-view--panel .persona-history-group-heading"
435
+ );
436
+ expect(clipped.slice(0, clipped.indexOf("{"))).not.toContain("--rail");
437
+ expect(css).toContain(".persona-history-view--panel .persona-history-group {\n margin: 0 -16px;\n}");
438
+ });
439
+
440
+ it("strips the rail down to single-line titles on their own surface", async () => {
441
+ mount({ presentation: "rail" });
442
+ await flush();
443
+ const css = injectedHistoryCss();
444
+
445
+ // Avatar, preview and time are panel constructs.
446
+ const hidden = css.slice(
447
+ css.indexOf(".persona-history-view--rail .persona-history-row-avatar")
448
+ );
449
+ const hiddenBlock = hidden.slice(0, hidden.indexOf("}"));
450
+ expect(hiddenBlock).toContain(".persona-history-view--rail .persona-history-row-avatar");
451
+ expect(hiddenBlock).toContain(".persona-history-view--rail .persona-history-row-preview");
452
+ expect(hiddenBlock).toContain(
453
+ ".persona-history-view--rail time.persona-history-row-time"
454
+ );
455
+ expect(hiddenBlock).toContain("display: none");
456
+
457
+ const row = css.slice(
458
+ css.indexOf(".persona-history-view--rail button.persona-history-row {")
459
+ );
460
+ const rowBlock = row.slice(0, row.indexOf("}"));
461
+ expect(rowBlock).toContain("min-height: 36px;");
462
+ expect(rowBlock).toContain("padding: 6px 10px;");
463
+ expect(rowBlock).toContain("margin: 0 6px;");
464
+ expect(rowBlock).toContain("border-radius: 10px;");
465
+
466
+ // The selection wash's divider-chain default cannot collapse into the
467
+ // surface color.
468
+ expect(css).toContain(
469
+ "--persona-history-row-active-bg: var(--persona-divider, var(--persona-border, #e5e7eb));"
470
+ );
471
+ // Its own sidebar surface, and no border: the host draws the divider.
472
+ expect(css).toContain(
473
+ "--persona-history-surface-bg: var(--persona-container, #f7f7f8);"
474
+ );
475
+ expect(css).not.toContain("border-left: 1px solid var(--persona-history-border)");
476
+ });
477
+
478
+ it("styles the rail's new-conversation action as a row above the list", async () => {
479
+ const { root } = mount({ presentation: "rail" });
480
+ await flush();
481
+ // A leading compose glyph, not the panel pill's trailing arrow.
482
+ const primary = root.querySelector<HTMLButtonElement>(".persona-history-new");
483
+ expect(
484
+ Array.from(primary!.querySelectorAll("svg path")).map((path) =>
485
+ path.getAttribute("d")
486
+ )
487
+ ).toEqual(["M5 12h14", "M12 5v14"]);
488
+
489
+ const css = injectedHistoryCss();
490
+ const rail = css.slice(
491
+ css.indexOf(".persona-history-view--rail button.persona-history-new {")
492
+ );
493
+ const block = rail.slice(0, rail.indexOf("}"));
494
+ expect(block).toContain("min-height: 36px;");
495
+ expect(block).toContain("padding: 6px 10px;");
496
+ expect(block).toContain("margin: 0 6px;");
497
+ expect(block).toContain("background: transparent;");
498
+ // The pill's sticky placement is panel-only.
499
+ expect(block).not.toContain("position: sticky;");
500
+ // The compose glyph leads in the DOM; no CSS reorder is needed.
501
+ expect(primary!.firstElementChild?.tagName.toLowerCase()).toBe("svg");
502
+ });
503
+ });
504
+
505
+ describe("history view grouping and pagination", () => {
506
+ it("renders one flat list under grouping none, starred still pinned", async () => {
507
+ const { root } = mount({
508
+ grouping: "none",
509
+ seeds: [
510
+ seed("a", 30 * MIN),
511
+ seed("s", 26 * HOUR, { starred: true }),
512
+ seed("c", 4 * DAY),
513
+ ],
514
+ });
515
+ await flush();
516
+
517
+ const groups = Array.from(
518
+ root.querySelectorAll<HTMLElement>("[data-persona-history-group]")
519
+ );
520
+ expect(groups.map((g) => g.dataset.personaHistoryGroup)).toEqual([
521
+ "starred",
522
+ "recent",
523
+ ]);
524
+ expect(rows(root)).toHaveLength(3);
525
+
526
+ // The pinned heading stays visible; the flat group's heading would repeat
527
+ // the list heading above it, so it labels the list sr-only instead.
528
+ const headings = groups.map(
529
+ (g) => g.querySelector<HTMLElement>(".persona-history-group-heading")!
530
+ );
531
+ expect(headings.map((h) => h.textContent)).toEqual([
532
+ "Starred",
533
+ "Conversations",
534
+ ]);
535
+ expect(headings[0].classList.contains("persona-history-sr-only")).toBe(false);
536
+ expect(headings[1].classList.contains("persona-history-sr-only")).toBe(true);
537
+ const list = groups[1].querySelector("ul.persona-history-list")!;
538
+ expect(list.getAttribute("aria-labelledby")).toBe(headings[1].id);
539
+ });
540
+
541
+ it("groups rows client-side without reordering them", async () => {
542
+ const { root } = mount();
543
+ await flush();
544
+
545
+ const groups = Array.from(
546
+ root.querySelectorAll<HTMLElement>("[data-persona-history-group]")
547
+ );
548
+ expect(groups.map((g) => g.dataset.personaHistoryGroup)).toEqual([
549
+ "today",
550
+ "yesterday",
551
+ "previous-7-days",
552
+ ]);
553
+ expect(
554
+ groups[0].querySelector(".persona-history-group-heading")?.textContent
555
+ ).toBe("Today");
556
+ expect(groups[0].querySelectorAll(".persona-history-row")).toHaveLength(2);
557
+ // Each group is a real list labelled by its heading.
558
+ const list = groups[0].querySelector("ul");
559
+ expect(list?.getAttribute("aria-labelledby")).toBe(
560
+ groups[0].querySelector(".persona-history-group-heading")?.id
561
+ );
562
+ });
563
+
564
+ it("merges the boundary group when load more appends a page", async () => {
565
+ const { root } = mount({
566
+ pageSize: 2,
567
+ seeds: [
568
+ seed("a", 10 * MIN),
569
+ seed("b", 30 * MIN),
570
+ seed("c", 2 * HOUR),
571
+ seed("d", 26 * HOUR),
572
+ ],
573
+ });
574
+ await flush();
575
+
576
+ expect(rows(root)).toHaveLength(2);
577
+ const loadMore = root.querySelector<HTMLButtonElement>(
578
+ ".persona-history-load-more"
579
+ );
580
+ expect(loadMore?.textContent).toBe("Load more");
581
+
582
+ loadMore?.click();
583
+ await flush();
584
+
585
+ const groups = Array.from(
586
+ root.querySelectorAll<HTMLElement>("[data-persona-history-group]")
587
+ );
588
+ expect(groups.map((g) => g.dataset.personaHistoryGroup)).toEqual([
589
+ "today",
590
+ "yesterday",
591
+ ]);
592
+ expect(groups[0].querySelectorAll(".persona-history-row")).toHaveLength(3);
593
+ expect(rows(root)).toHaveLength(4);
594
+ expect(root.querySelector(".persona-history-load-more")).toBeNull();
595
+ });
596
+
597
+ it("keeps load more shape-stable and busy while a page is in flight", async () => {
598
+ const { root, provider } = mount({
599
+ pageSize: 2,
600
+ seeds: [seed("a", 10 * MIN), seed("b", 30 * MIN), seed("c", 2 * HOUR)],
601
+ });
602
+ await flush();
603
+ provider.setLatency(20);
604
+
605
+ const loadMore = root.querySelector<HTMLButtonElement>(
606
+ ".persona-history-load-more"
607
+ );
608
+ loadMore?.click();
609
+
610
+ const busyButton = root.querySelector<HTMLButtonElement>(
611
+ ".persona-history-load-more"
612
+ );
613
+ expect(busyButton?.getAttribute("aria-busy")).toBe("true");
614
+ expect(busyButton?.textContent).toBe("Loading more conversations");
615
+ // Stale rows stay put while the next page loads.
616
+ expect(rows(root)).toHaveLength(2);
617
+
618
+ provider.setLatency(0);
619
+ await new Promise((resolve) => setTimeout(resolve, 40));
620
+ expect(rows(root)).toHaveLength(3);
621
+ });
622
+ });
623
+
624
+ describe("history view row actions", () => {
625
+ it("shows a per-row pending state until the select promise settles", async () => {
626
+ let resolveSelect: (() => void) | undefined;
627
+ const onSelect = vi.fn(
628
+ () =>
629
+ new Promise<void>((resolve) => {
630
+ resolveSelect = resolve;
631
+ })
632
+ );
633
+ const { root } = mount({ onSelect });
634
+ await flush();
635
+
636
+ rowFor(root, "a").click();
637
+ expect(onSelect).toHaveBeenCalledWith("a");
638
+ expect(rowFor(root, "a").getAttribute("aria-busy")).toBe("true");
639
+ expect(rowFor(root, "a").getAttribute("aria-disabled")).toBe("true");
640
+ expect(rowFor(root, "b").getAttribute("aria-disabled")).toBe("true");
641
+ expect(
642
+ root
643
+ .querySelector(".persona-history-new")
644
+ ?.getAttribute("aria-disabled")
645
+ ).toBe("true");
646
+
647
+ // A second click while pending does not re-fire.
648
+ rowFor(root, "b").click();
649
+ expect(onSelect).toHaveBeenCalledTimes(1);
650
+
651
+ resolveSelect?.();
652
+ await flush();
653
+
654
+ expect(rowFor(root, "a").hasAttribute("aria-busy")).toBe(false);
655
+ expect(rowFor(root, "a").getAttribute("aria-current")).toBe("page");
656
+ });
657
+
658
+ it("keeps the list and shows a row-adjacent retry when select fails", async () => {
659
+ const onSelect = vi
660
+ .fn<(id: string) => Promise<void>>()
661
+ .mockRejectedValueOnce(new Error("nope"))
662
+ .mockResolvedValue(undefined);
663
+ const { root } = mount({ onSelect });
664
+ await flush();
665
+
666
+ rowFor(root, "a").click();
667
+ await flush();
668
+
669
+ expect(rows(root)).toHaveLength(4);
670
+ const item = root.querySelector<HTMLElement>('[data-persona-history-item="a"]');
671
+ const error = item?.querySelector<HTMLElement>(".persona-history-row-error");
672
+ expect(error?.getAttribute("role")).toBe("alert");
673
+ expect(error?.textContent).toContain("Could not open that conversation.");
674
+
675
+ error?.querySelector("button")?.click();
676
+ await flush();
677
+ expect(onSelect).toHaveBeenCalledTimes(2);
678
+ expect(
679
+ root.querySelector('[data-persona-history-item="a"] .persona-history-row-error')
680
+ ).toBeNull();
681
+ });
682
+
683
+ it("opens a labelled overflow menu that is keyboard operable", async () => {
684
+ const { root } = mount();
685
+ await flush();
686
+
687
+ const trigger = menuButtonFor(root, "a");
688
+ expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
689
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
690
+ expect(root.querySelector('[role="menu"]')).toBeNull();
691
+
692
+ trigger.dispatchEvent(
693
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })
694
+ );
695
+
696
+ const menu = root.querySelector<HTMLElement>('[role="menu"]');
697
+ expect(menu?.getAttribute("aria-label")).toContain("Conversation options");
698
+ const item = menu?.querySelector<HTMLElement>('[role="menuitem"]');
699
+ expect(item?.textContent).toBe("Delete");
700
+ expect(document.activeElement).toBe(item);
701
+ expect(menuButtonFor(root, "a").getAttribute("aria-expanded")).toBe("true");
702
+
703
+ item?.dispatchEvent(
704
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
705
+ );
706
+ expect(root.querySelector('[role="menu"]')).toBeNull();
707
+ expect(document.activeElement).toBe(menuButtonFor(root, "a"));
708
+ });
709
+
710
+ it("never renders a permanent trash affordance on the row", async () => {
711
+ const { root } = mount();
712
+ await flush();
713
+ expect(root.querySelector(".persona-history-menu-item")).toBeNull();
714
+ expect(root.querySelectorAll(".persona-history-row-menu-button")).toHaveLength(
715
+ 4
716
+ );
717
+ });
718
+
719
+ it("drops the row overflow trigger entirely when showDelete is false", async () => {
720
+ const { root, onSelect } = mount({ showDelete: false });
721
+ await flush();
722
+
723
+ expect(root.querySelectorAll(".persona-history-row-menu-button")).toHaveLength(
724
+ 0
725
+ );
726
+ expect(root.querySelector('[role="menu"]')).toBeNull();
727
+ // The modifier keeps the hover time-swap and preview taper from firing
728
+ // with no trigger to swap in.
729
+ expect(root.querySelectorAll("li.persona-history-item--no-menu")).toHaveLength(
730
+ 4
731
+ );
732
+ // Rows still open normally.
733
+ rowFor(root, "a").click();
734
+ await flush();
735
+ expect(onSelect).toHaveBeenCalledWith("a");
736
+ });
737
+
738
+ it("reports the active conversation's summary through onActiveConversationChange", async () => {
739
+ const onActiveConversationChange = vi.fn();
740
+ const { handle } = mount({
741
+ activeConversationId: "a",
742
+ onActiveConversationChange,
743
+ });
744
+ await flush();
745
+ expect(onActiveConversationChange).toHaveBeenLastCalledWith(
746
+ expect.objectContaining({ id: "a", title: "Session a" })
747
+ );
748
+
749
+ handle.setActiveConversationId("b");
750
+ expect(onActiveConversationChange).toHaveBeenLastCalledWith(
751
+ expect.objectContaining({ id: "b", title: "Session b" })
752
+ );
753
+
754
+ // Only changes are reported: re-rendering with the same active id is silent.
755
+ const calls = onActiveConversationChange.mock.calls.length;
756
+ handle.setActiveConversationId("b");
757
+ expect(onActiveConversationChange.mock.calls.length).toBe(calls);
758
+
759
+ handle.setActiveConversationId(null);
760
+ expect(onActiveConversationChange).toHaveBeenLastCalledWith(null);
761
+ });
762
+
763
+ it("patches one summary in place and re-reports through applyConversationSummary", async () => {
764
+ const onActiveConversationChange = vi.fn();
765
+ const { root, handle } = mount({
766
+ activeConversationId: "a",
767
+ onActiveConversationChange,
768
+ });
769
+ await flush();
770
+
771
+ const base = {
772
+ targetId: "t1",
773
+ preview: "Preview for a",
774
+ messageCount: 2,
775
+ createdAt: at(31 * MIN),
776
+ updatedAt: at(30 * MIN),
777
+ };
778
+ handle.applyConversationSummary({
779
+ id: "a",
780
+ title: "Renamed session",
781
+ starred: true,
782
+ ...base,
783
+ });
784
+
785
+ // The row restamps, the starred group pins first, and the reporter fires.
786
+ expect(rowFor(root, "a").textContent).toContain("Renamed session");
787
+ expect(
788
+ root.querySelector(".persona-history-group")?.getAttribute(
789
+ "data-persona-history-group"
790
+ )
791
+ ).toBe("starred");
792
+ expect(root.querySelector(".persona-history-row-star")).not.toBeNull();
793
+ expect(onActiveConversationChange).toHaveBeenLastCalledWith(
794
+ expect.objectContaining({ title: "Renamed session", starred: true })
795
+ );
796
+
797
+ // Unknown ids are ignored.
798
+ handle.applyConversationSummary({
799
+ id: "ghost",
800
+ title: "Ghost",
801
+ ...base,
802
+ });
803
+ expect(rowFor(root, "a").textContent).toContain("Renamed session");
804
+ });
805
+
806
+ it("drops the list overflow trigger when showDeleteAll is false", async () => {
807
+ const { root } = mount({ showDeleteAll: false });
808
+ await flush();
809
+
810
+ // No reset control on this provider either, so the menu has nothing to hold.
811
+ expect(listOptions(root)).toBeNull();
812
+ // Per-row delete stays independent of the delete-all switch.
813
+ menuButtonFor(root, "a").click();
814
+ expect(root.querySelector('[role="menuitem"]')).not.toBeNull();
815
+ });
816
+
817
+ it("removes the row when the shell reports a delete", async () => {
818
+ const { root, onRequestDeleteConversation } = mount();
819
+ await flush();
820
+
821
+ menuButtonFor(root, "a").click();
822
+ root.querySelector<HTMLElement>('[role="menuitem"]')?.click();
823
+ await flush();
824
+
825
+ expect(onRequestDeleteConversation).toHaveBeenCalledWith("a");
826
+ expect(rows(root)).toHaveLength(3);
827
+ expect(liveText(root)).toBe("Conversation deleted.");
828
+ });
829
+
830
+ it("keeps the row when the shell reports a cancelled delete", async () => {
831
+ const onRequestDeleteConversation = vi.fn(async () => "cancelled" as const);
832
+ const { root } = mount({ onRequestDeleteConversation });
833
+ await flush();
834
+
835
+ menuButtonFor(root, "a").click();
836
+ root.querySelector<HTMLElement>('[role="menuitem"]')?.click();
837
+ await flush();
838
+
839
+ expect(rows(root)).toHaveLength(4);
840
+ expect(liveText(root)).toBe("");
841
+ });
842
+
843
+ it("removes the row silently on not_found and shows a retry otherwise", async () => {
844
+ const onRequestDeleteConversation = vi
845
+ .fn<(id: string) => Promise<"deleted" | "cancelled">>()
846
+ .mockRejectedValueOnce(
847
+ new HistoryProviderError("not_found", "Conversation not found.")
848
+ )
849
+ .mockRejectedValueOnce(new Error("boom"));
850
+ const { root } = mount({ onRequestDeleteConversation });
851
+ await flush();
852
+
853
+ menuButtonFor(root, "a").click();
854
+ root.querySelector<HTMLElement>('[role="menuitem"]')?.click();
855
+ await flush();
856
+ expect(rows(root)).toHaveLength(3);
857
+ expect(root.querySelector(".persona-history-row-error")).toBeNull();
858
+ expect(liveText(root)).toBe("");
859
+
860
+ menuButtonFor(root, "b").click();
861
+ root.querySelector<HTMLElement>('[role="menuitem"]')?.click();
862
+ await flush();
863
+ expect(rows(root)).toHaveLength(3);
864
+ expect(
865
+ root.querySelector('[data-persona-history-item="b"] .persona-history-row-error')
866
+ ?.textContent
867
+ ).toContain("Could not delete that conversation.");
868
+ });
869
+ });
870
+
871
+ describe("history view list states", () => {
872
+ it("renders three row-shaped skeletons with no fake text while loading", () => {
873
+ const { root } = mount();
874
+ const block = stateBlock(root);
875
+ expect(block?.dataset.personaHistoryState).toBe("loading");
876
+ expect(block?.getAttribute("role")).toBe("status");
877
+ expect(block?.getAttribute("aria-label")).toBe("Loading conversations");
878
+ expect(block?.querySelectorAll(".persona-history-skeleton-row")).toHaveLength(3);
879
+ // Mirrors the row anatomy: head line with title and time bars, preview under.
880
+ const first = block?.querySelector(".persona-history-skeleton-row");
881
+ expect(
882
+ first?.querySelector(
883
+ ".persona-history-skeleton-head .persona-history-skeleton-bar--title"
884
+ )
885
+ ).not.toBeNull();
886
+ expect(
887
+ first?.querySelector(
888
+ ".persona-history-skeleton-head .persona-history-skeleton-bar--time"
889
+ )
890
+ ).not.toBeNull();
891
+ expect(
892
+ first?.querySelector(".persona-history-skeleton-bar--preview")
893
+ ).not.toBeNull();
894
+ expect(block?.textContent).toBe("");
895
+ });
896
+
897
+ it("keeps the overflow trigger in place but inert until the first load resolves", async () => {
898
+ const { root } = mount();
899
+ // Chrome, so the trigger holds its slot rather than popping in behind the
900
+ // rows; there is just nothing to offer yet.
901
+ const trigger = listOptions(root)!;
902
+ expect(trigger.hidden).toBe(false);
903
+ expect(trigger.getAttribute("aria-disabled")).toBe("true");
904
+ trigger.click();
905
+ expect(root.querySelector(".persona-history-caption .persona-history-menu")).toBeNull();
906
+
907
+ await flush();
908
+ expect(trigger.hidden).toBe(false);
909
+ expect(trigger.hasAttribute("aria-disabled")).toBe(false);
910
+ expect(openListMenu(root).map((item) => item.textContent)).toEqual([
911
+ "Delete all conversations",
912
+ ]);
913
+ });
914
+
915
+ it("renders the empty state and drops the trigger when there is nothing to act on", async () => {
916
+ const { root } = mount({ seeds: [] });
917
+ await flush();
918
+
919
+ const block = stateBlock(root);
920
+ expect(block?.dataset.personaHistoryState).toBe("empty");
921
+ expect(block?.textContent).toContain("No conversations yet");
922
+ expect(rows(root)).toHaveLength(0);
923
+ // The primary action stays prominent.
924
+ expect(root.querySelector(".persona-history-new")).not.toBeNull();
925
+ // Delete-all is the only item this provider can offer, and there is
926
+ // nothing to delete.
927
+ expect(listOptions(root)?.hidden).toBe(true);
928
+ });
929
+
930
+ it("renders a retryable error with an adjacent retry", async () => {
931
+ const { root, provider } = mount();
932
+ await flush();
933
+ provider.setFailure("list", { code: "unavailable" });
934
+ root
935
+ .querySelector<HTMLButtonElement>('[data-persona-history-focus="close"]')
936
+ ?.blur();
937
+
938
+ const { handle } = mounted[mounted.length - 1];
939
+ handle.refresh();
940
+ await flush();
941
+
942
+ const block = stateBlock(root);
943
+ expect(block?.dataset.personaHistoryState).toBe("error");
944
+ expect(block?.getAttribute("role")).toBe("alert");
945
+ expect(block?.textContent).toContain("Conversation history is unavailable");
946
+
947
+ provider.clearFailures();
948
+ block?.querySelector<HTMLButtonElement>("button")?.click();
949
+ await flush();
950
+ expect(rows(root)).toHaveLength(4);
951
+ });
952
+
953
+ it("renders the rate-limited soft state with its retry-after seconds", async () => {
954
+ const { root, provider, handle } = mount();
955
+ await flush();
956
+ provider.setFailure("list", { code: "rate_limited", retryAfterSeconds: 30 });
957
+ handle.refresh();
958
+ await flush();
959
+
960
+ const block = stateBlock(root);
961
+ expect(block?.dataset.personaHistoryState).toBe("rate_limited");
962
+ expect(block?.textContent).toContain("You can try again in 30 seconds.");
963
+ });
964
+
965
+ it("offers no retry for an unsupported scope", async () => {
966
+ const { root } = mount({ context: { scope: "verified-user" } });
967
+ await flush();
968
+
969
+ const block = stateBlock(root);
970
+ expect(block?.dataset.personaHistoryState).toBe("error");
971
+ expect(block?.textContent).toContain("Account history is unavailable");
972
+ expect(block?.querySelector("button")).toBeNull();
973
+ });
974
+
975
+ it("recovers from new_conversation_required through onStartNew", async () => {
976
+ const { root, handle, onStartNew } = mount();
977
+ await flush();
978
+
979
+ handle.setNewConversationRequired(true);
980
+ const block = stateBlock(root);
981
+ expect(block?.dataset.personaHistoryState).toBe("new_conversation_required");
982
+ const action = block?.querySelector<HTMLButtonElement>("button");
983
+ expect(action?.textContent).toBe("New conversation");
984
+
985
+ action?.click();
986
+ await flush();
987
+ expect(onStartNew).toHaveBeenCalledTimes(1);
988
+ expect(stateBlock(root)).toBeNull();
989
+ });
990
+
991
+ it("keeps the top-bar controls in place across every list state", async () => {
992
+ const { root, provider, handle } = mount();
993
+ await flush();
994
+ const controls = () =>
995
+ Array.from(
996
+ root.querySelectorAll(".persona-history-topbar button")
997
+ ).map((button) => button.getAttribute("aria-label"));
998
+ const before = controls();
999
+
1000
+ provider.setFailure("list", { code: "unavailable" });
1001
+ handle.refresh();
1002
+ await flush();
1003
+ expect(controls()).toEqual(before);
1004
+
1005
+ provider.clearFailures();
1006
+ handle.refresh();
1007
+ await flush();
1008
+ expect(controls()).toEqual(before);
1009
+ });
1010
+ });
1011
+
1012
+ describe("history view scope status", () => {
1013
+ it("collapses the browser-only scope to one body caption with an sr-only description", async () => {
1014
+ const { root } = mount();
1015
+ await flush();
1016
+
1017
+ expect(root.querySelector(".persona-history-scope-title")?.textContent).toBe(
1018
+ "On this device"
1019
+ );
1020
+ const block = root.querySelector<HTMLElement>(".persona-history-scope-alert");
1021
+ expect(block?.dataset.personaHistoryIdentity).toBe("browser_only");
1022
+ expect(block?.textContent).toContain("separate history");
1023
+ expect(block?.hasAttribute("role")).toBe(false);
1024
+ // No second visual band: the sentence is reachable only through the caption.
1025
+ expect(block?.dataset.personaHistoryScopeTone).toBe("ambient");
1026
+ const line = root.querySelector<HTMLElement>(".persona-history-scope")!;
1027
+ const describedBy = line.getAttribute("aria-describedby");
1028
+ expect(describedBy).toBeTruthy();
1029
+ expect(
1030
+ root.querySelector(`#${describedBy}`)?.classList.contains(
1031
+ "persona-history-scope-description"
1032
+ )
1033
+ ).toBe(true);
1034
+ });
1035
+
1036
+ it("keeps every scope surface below the bar, above the list", async () => {
1037
+ const { root, provider } = mount();
1038
+ await flush();
1039
+ const body = root.querySelector<HTMLElement>(".persona-history-body")!;
1040
+ const bar = root.querySelector<HTMLElement>(".persona-history-topbar")!;
1041
+ const line = root.querySelector<HTMLElement>(".persona-history-scope")!;
1042
+ const block = root.querySelector<HTMLElement>(".persona-history-scope-alert")!;
1043
+
1044
+ expect(bar.contains(line)).toBe(false);
1045
+ expect(body.contains(line)).toBe(true);
1046
+ expect(body.contains(block)).toBe(true);
1047
+ expect(line.hidden).toBe(false);
1048
+ expect(
1049
+ line.compareDocumentPosition(
1050
+ root.querySelector(".persona-history-list-region") as Node
1051
+ )
1052
+ ).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
1053
+
1054
+ // Actionable states speak through the block; the caption would repeat it.
1055
+ provider.setIdentityStatus({
1056
+ state: "authentication_required",
1057
+ reason: "invalid_identity_proof",
1058
+ });
1059
+ await flush();
1060
+ expect(line.hidden).toBe(true);
1061
+ expect(
1062
+ block.querySelector(".persona-history-scope-alert-title")?.textContent
1063
+ ).toBe("Sign in to see your messages");
1064
+ });
1065
+
1066
+ it("keeps the verified scope ambient and restores the band for attention states", async () => {
1067
+ const { root, provider } = mount();
1068
+ await flush();
1069
+ const block = root.querySelector<HTMLElement>(".persona-history-scope-alert")!;
1070
+ const line = root.querySelector<HTMLElement>(".persona-history-scope")!;
1071
+
1072
+ provider.setIdentityStatus({ state: "verified" });
1073
+ await flush();
1074
+ expect(block.dataset.personaHistoryScopeTone).toBe("ambient");
1075
+ expect(line.hasAttribute("aria-describedby")).toBe(true);
1076
+
1077
+ // Actionable states stay prominent and drop the duplicate description link.
1078
+ provider.setIdentityStatus({ state: "verifying" });
1079
+ await flush();
1080
+ expect(block.dataset.personaHistoryScopeTone).toBe("attention");
1081
+ expect(line.hasAttribute("aria-describedby")).toBe(false);
1082
+
1083
+ provider.setIdentityStatus({ state: "identity_provider_failed" });
1084
+ await flush();
1085
+ expect(block.dataset.personaHistoryScopeTone).toBe("attention");
1086
+ expect(line.hasAttribute("aria-describedby")).toBe(false);
1087
+ });
1088
+
1089
+ it("announces an identity transition once and not on re-render", async () => {
1090
+ const { root, provider, handle } = mount();
1091
+ await flush();
1092
+ const subtitle = root.querySelector(".persona-history-scope-title");
1093
+
1094
+ provider.setIdentityStatus({ state: "verifying" });
1095
+ expect(
1096
+ root
1097
+ .querySelector(".persona-history-scope-alert")
1098
+ ?.getAttribute("role")
1099
+ ).toBe("status");
1100
+ expect(subtitle?.textContent).toBe("Checking your account");
1101
+ // A progress state speaks through its own status region, not the announcer.
1102
+ await flush();
1103
+ expect(liveText(root)).toBe("");
1104
+
1105
+ provider.setIdentityStatus({ state: "verified" });
1106
+ await flush();
1107
+ expect(subtitle?.textContent).toBe("Available across signed-in devices");
1108
+ expect(liveText(root)).toBe("Available across signed-in devices");
1109
+ // Same node patched in place: a stable subtitle cannot re-announce.
1110
+ expect(root.querySelector(".persona-history-scope-title")).toBe(subtitle);
1111
+
1112
+ handle.refresh();
1113
+ await flush();
1114
+ expect(root.querySelector(".persona-history-scope-title")).toBe(subtitle);
1115
+ });
1116
+
1117
+ it("explains an identity failure and offers a retry", async () => {
1118
+ const { root, provider } = mount();
1119
+ await flush();
1120
+
1121
+ provider.setIdentityStatus({
1122
+ state: "authentication_required",
1123
+ reason: "invalid_identity_proof",
1124
+ });
1125
+ const block = root.querySelector<HTMLElement>(".persona-history-scope-alert");
1126
+ expect(block?.dataset.personaHistoryScopeTone).toBe("attention");
1127
+ expect(block?.textContent).toContain("Sign in again to load account history.");
1128
+ expect(
1129
+ block?.querySelector<HTMLElement>(
1130
+ '[data-persona-history-focus="identity-retry"]'
1131
+ )?.textContent
1132
+ ).toBe("Try again");
1133
+ });
1134
+
1135
+ it("omits the scope status entirely when disabled", async () => {
1136
+ const { root } = mount({ showScopeStatus: false });
1137
+ await flush();
1138
+ expect(root.querySelector(".persona-history-scope")).toBeNull();
1139
+ expect(root.querySelector(".persona-history-scope-alert")).toBeNull();
1140
+ });
1141
+ });
1142
+
1143
+ describe("history view chrome and destructive actions", () => {
1144
+ it("meets the top-bar anatomy contract", async () => {
1145
+ const { root, onClose, onStartNew } = mount();
1146
+ await flush();
1147
+
1148
+ const topbar = root.querySelector<HTMLElement>(".persona-history-topbar");
1149
+ expect(topbar?.querySelector("h2")?.textContent).toBe("Messages");
1150
+
1151
+ const back = root.querySelector<HTMLButtonElement>(
1152
+ '[data-persona-history-focus="close"]'
1153
+ );
1154
+ expect(back?.getAttribute("aria-label")).toBe("Back to conversation");
1155
+ back?.click();
1156
+ expect(onClose).toHaveBeenCalledTimes(1);
1157
+
1158
+ const newIcon = topbar?.querySelector<HTMLButtonElement>(
1159
+ '[data-persona-history-focus="new-icon"]'
1160
+ );
1161
+ expect(newIcon?.getAttribute("aria-label")).toBe("New conversation");
1162
+ newIcon?.click();
1163
+ await flush();
1164
+ expect(onStartNew).toHaveBeenCalledTimes(1);
1165
+
1166
+ // Every pointer control carries the 44px target class.
1167
+ for (const button of Array.from(
1168
+ root.querySelectorAll(".persona-history-topbar button")
1169
+ )) {
1170
+ expect(button.classList.contains("persona-history-icon-button")).toBe(true);
1171
+ }
1172
+ });
1173
+
1174
+ it("labels the rail close control differently from the panel back control", async () => {
1175
+ const { root } = mount({ presentation: "rail", collapsible: false });
1176
+ await flush();
1177
+ expect(root.classList.contains("persona-history-view--rail")).toBe(true);
1178
+ expect(root.dataset.personaHistoryPresentation).toBe("rail");
1179
+ expect(
1180
+ root
1181
+ .querySelector('[data-persona-history-focus="close"]')
1182
+ ?.getAttribute("aria-label")
1183
+ ).toBe("Close conversation list");
1184
+ });
1185
+
1186
+ it("turns the collapsible rail's leading control into a collapse toggle", async () => {
1187
+ const onToggleCollapse = vi.fn();
1188
+ const { root, handle } = mount({ presentation: "rail", onToggleCollapse });
1189
+ await flush();
1190
+ const toggle = root.querySelector<HTMLButtonElement>(
1191
+ '[data-persona-history-focus="collapse"]'
1192
+ )!;
1193
+ expect(toggle).not.toBeNull();
1194
+ expect(root.querySelector('[data-persona-history-focus="close"]')).toBeNull();
1195
+ expect(toggle.getAttribute("aria-label")).toBe("Collapse conversation list");
1196
+ expect(toggle.getAttribute("aria-expanded")).toBe("true");
1197
+ // Controls the body region it hides, which carries the list.
1198
+ const body = root.querySelector<HTMLElement>(".persona-history-body")!;
1199
+ expect(toggle.getAttribute("aria-controls")).toBe(body.id);
1200
+ expect(body.id).not.toBe("");
1201
+ // Lucide PanelLeft: the rounded plate plus its divider.
1202
+ expect(toggle.querySelector("rect")).not.toBeNull();
1203
+ expect(toggle.querySelector("path")?.getAttribute("d")).toBe("M9 3v18");
1204
+
1205
+ toggle.click();
1206
+ expect(onToggleCollapse).toHaveBeenCalledTimes(1);
1207
+
1208
+ // Panel keeps the back arrow and the close focus key untouched.
1209
+ handle.setPresentation("panel");
1210
+ await flush();
1211
+ const back = root.querySelector<HTMLButtonElement>(
1212
+ '[data-persona-history-focus="close"]'
1213
+ )!;
1214
+ expect(back.getAttribute("aria-label")).toBe("Back to conversation");
1215
+ expect(back.hasAttribute("aria-expanded")).toBe(false);
1216
+ expect(back.querySelector("rect")).toBeNull();
1217
+ });
1218
+
1219
+ it("keeps the rail's close control when collapse is turned off", async () => {
1220
+ const onClose = vi.fn();
1221
+ const { root } = mount({
1222
+ presentation: "rail",
1223
+ collapsible: false,
1224
+ onClose,
1225
+ });
1226
+ await flush();
1227
+ expect(root.querySelector('[data-persona-history-focus="collapse"]')).toBeNull();
1228
+ root
1229
+ .querySelector<HTMLButtonElement>('[data-persona-history-focus="close"]')!
1230
+ .click();
1231
+ expect(onClose).toHaveBeenCalledTimes(1);
1232
+ });
1233
+
1234
+ it("stamps the collapsed rail treatment and relabels its toggle", async () => {
1235
+ const { root, handle } = mount({ presentation: "rail", collapsed: true });
1236
+ await flush();
1237
+ const toggle = () =>
1238
+ root.querySelector<HTMLButtonElement>(
1239
+ '[data-persona-history-focus="collapse"]'
1240
+ )!;
1241
+ expect(root.classList.contains("persona-history-view--rail-collapsed")).toBe(
1242
+ true
1243
+ );
1244
+ expect(toggle().getAttribute("aria-label")).toBe("Expand conversation list");
1245
+ expect(toggle().getAttribute("aria-expanded")).toBe("false");
1246
+
1247
+ handle.setCollapsed(false);
1248
+ expect(root.classList.contains("persona-history-view--rail-collapsed")).toBe(
1249
+ false
1250
+ );
1251
+ expect(toggle().getAttribute("aria-label")).toBe("Collapse conversation list");
1252
+ expect(toggle().getAttribute("aria-expanded")).toBe("true");
1253
+
1254
+ // Panel never wears the treatment, whatever the last rail state was.
1255
+ handle.setCollapsed(true);
1256
+ handle.setPresentation("panel");
1257
+ await flush();
1258
+ expect(root.classList.contains("persona-history-view--rail-collapsed")).toBe(
1259
+ false
1260
+ );
1261
+ });
1262
+
1263
+ it("hides everything but the two icons in the collapsed rail", async () => {
1264
+ mount({ presentation: "rail", collapsed: true });
1265
+ await flush();
1266
+ const css = injectedHistoryCss();
1267
+ const hidden = css.slice(
1268
+ css.indexOf(
1269
+ ".persona-history-view--rail-collapsed .persona-history-heading-group"
1270
+ )
1271
+ );
1272
+ const hiddenBlock = hidden.slice(0, hidden.indexOf("}"));
1273
+ // The list, the scope caption and the footer are all body children.
1274
+ expect(hiddenBlock).toContain(
1275
+ ".persona-history-view--rail-collapsed .persona-history-body > :not(.persona-history-new)"
1276
+ );
1277
+ expect(hiddenBlock).toContain("display: none;");
1278
+ // The host's width animation is a rule, so it can honor reduced motion.
1279
+ expect(css).toContain(
1280
+ ".persona-history-rail-host {\n transition: flex-basis"
1281
+ );
1282
+ expect(css.slice(css.indexOf("@media (prefers-reduced-motion"))).toContain(
1283
+ ".persona-history-rail-host { transition: none; }"
1284
+ );
1285
+ // The new-conversation row squares off into a centered icon button.
1286
+ const row = css.slice(
1287
+ css.indexOf(
1288
+ ".persona-history-view--rail-collapsed button.persona-history-new {"
1289
+ )
1290
+ );
1291
+ const rowBlock = row.slice(0, row.indexOf("}"));
1292
+ expect(rowBlock).toContain("width: 36px;");
1293
+ expect(rowBlock).toContain("margin: 0 auto;");
1294
+ expect(css).toContain(
1295
+ ".persona-history-view--rail-collapsed button.persona-history-new span {\n display: none;\n}"
1296
+ );
1297
+ });
1298
+
1299
+ it("carries one new-conversation control per presentation", async () => {
1300
+ const { root, handle } = mount({ presentation: "rail" });
1301
+ await flush();
1302
+ const barIcon = () =>
1303
+ root.querySelector<HTMLButtonElement>(
1304
+ '.persona-history-topbar [data-persona-history-focus="new-icon"]'
1305
+ );
1306
+ // The rail's body row is the only one; the panel bar carries the icon.
1307
+ expect(barIcon()).toBeNull();
1308
+ expect(root.querySelector(".persona-history-new")).not.toBeNull();
1309
+
1310
+ handle.setPresentation("panel");
1311
+ await flush();
1312
+ const icon = barIcon();
1313
+ expect(icon).not.toBeNull();
1314
+ // Restored as the trailing child, so the heading stays centered.
1315
+ expect(icon?.previousElementSibling?.className).toBe(
1316
+ "persona-history-heading-group"
1317
+ );
1318
+ expect(icon?.nextElementSibling).toBeNull();
1319
+
1320
+ handle.setPresentation("rail");
1321
+ await flush();
1322
+ expect(barIcon()).toBeNull();
1323
+ });
1324
+
1325
+ it("gives the rail bar only the tracks it fills", async () => {
1326
+ mount({ presentation: "rail" });
1327
+ await flush();
1328
+ const css = injectedHistoryCss();
1329
+ const rail = css.slice(
1330
+ css.indexOf(".persona-history-view--rail .persona-history-topbar {")
1331
+ );
1332
+ // Identity leads, the toggle takes the trailing (inner) track, sized to it.
1333
+ expect(rail.slice(0, rail.indexOf("}"))).toContain(
1334
+ "grid-template-columns: minmax(0, 1fr) auto;"
1335
+ );
1336
+ const mirrored = css.slice(
1337
+ css.indexOf(".persona-history-view--rail-right .persona-history-topbar {")
1338
+ );
1339
+ expect(mirrored.slice(0, mirrored.indexOf("}"))).toContain(
1340
+ "grid-template-columns: auto minmax(0, 1fr);"
1341
+ );
1342
+ // The heading takes the rows' 16px text inset on whichever edge it leads.
1343
+ expect(css).toContain(
1344
+ ".persona-history-view--rail .persona-history-heading-group {\n text-align: left;"
1345
+ );
1346
+ // The panel and shell bars keep the third track for their icon.
1347
+ expect(css).toContain("grid-template-columns: 44px minmax(0, 1fr) 44px;");
1348
+ });
1349
+
1350
+ it("sizes the rail toggle to 36px with a coarse-pointer 44px floor", async () => {
1351
+ mount({ presentation: "rail" });
1352
+ await flush();
1353
+ const css = injectedHistoryCss();
1354
+ const selector =
1355
+ ".persona-history-view--rail .persona-history-topbar button.persona-history-icon-button {";
1356
+ const box = css.slice(css.indexOf(selector));
1357
+ const boxBlock = box.slice(0, box.indexOf("}"));
1358
+ for (const property of ["width", "height", "min-width", "min-height"]) {
1359
+ expect(boxBlock).toContain(`${property}: 36px;`);
1360
+ }
1361
+ // The panel and shell bars keep the 44px header control box.
1362
+ const shared = css.slice(
1363
+ css.indexOf(".persona-history-view button.persona-history-icon-button,")
1364
+ );
1365
+ expect(shared.slice(0, shared.indexOf("}"))).toContain("width: 44px;");
1366
+ // Touch has no hover to aim with, so the target goes back to 44px.
1367
+ const coarse = css.slice(css.indexOf("@media (pointer: coarse)"));
1368
+ expect(coarse).toContain(` ${selector}`);
1369
+ expect(coarse.slice(0, coarse.indexOf("\n}"))).toContain("width: 44px;");
1370
+ });
1371
+
1372
+ it("puts the rail's collapse toggle on the edge facing the conversation", async () => {
1373
+ const { root, handle } = mount({ presentation: "rail" });
1374
+ await flush();
1375
+ const bar = root.querySelector<HTMLElement>(".persona-history-topbar")!;
1376
+ const order = () =>
1377
+ Array.from(bar.children).map((child) => child.className.split(" ")[0]);
1378
+ // Rail on the left: heading first, toggle on the trailing inner edge.
1379
+ expect(order()).toEqual([
1380
+ "persona-history-heading-group",
1381
+ "persona-history-icon-button",
1382
+ ]);
1383
+ expect(bar.lastElementChild?.getAttribute("data-persona-history-focus")).toBe(
1384
+ "collapse"
1385
+ );
1386
+ expect(root.classList.contains("persona-history-view--rail-right")).toBe(
1387
+ false
1388
+ );
1389
+
1390
+ // Panel keeps the back arrow leading and the icon trailing.
1391
+ handle.setPresentation("panel");
1392
+ await flush();
1393
+ expect(order()).toEqual([
1394
+ "persona-history-icon-button",
1395
+ "persona-history-heading-group",
1396
+ "persona-history-icon-button",
1397
+ ]);
1398
+ expect(bar.firstElementChild?.getAttribute("data-persona-history-focus")).toBe(
1399
+ "close"
1400
+ );
1401
+ });
1402
+
1403
+ it("mirrors the rail bar when the rail docks on the right", async () => {
1404
+ const { root, handle } = mount({ presentation: "rail", railSide: "right" });
1405
+ await flush();
1406
+ const bar = root.querySelector<HTMLElement>(".persona-history-topbar")!;
1407
+ // Inner edge faces left, so the toggle leads and the heading trails.
1408
+ expect(bar.firstElementChild?.getAttribute("data-persona-history-focus")).toBe(
1409
+ "collapse"
1410
+ );
1411
+ expect(bar.lastElementChild?.className).toBe("persona-history-heading-group");
1412
+ expect(root.classList.contains("persona-history-view--rail-right")).toBe(true);
1413
+
1414
+ handle.setRailSide("left");
1415
+ expect(bar.lastElementChild?.getAttribute("data-persona-history-focus")).toBe(
1416
+ "collapse"
1417
+ );
1418
+ expect(root.classList.contains("persona-history-view--rail-right")).toBe(
1419
+ false
1420
+ );
1421
+
1422
+ // Panel never wears the mirrored treatment.
1423
+ handle.setRailSide("right");
1424
+ handle.setPresentation("panel");
1425
+ await flush();
1426
+ expect(root.classList.contains("persona-history-view--rail-right")).toBe(
1427
+ false
1428
+ );
1429
+ expect(bar.firstElementChild?.getAttribute("data-persona-history-focus")).toBe(
1430
+ "close"
1431
+ );
1432
+ });
1433
+
1434
+ it("renders a host brand in the rail header and keeps the title for the name", async () => {
1435
+ const seen: Array<{ collapsed: boolean; defaultTitle: string }> = [];
1436
+ const renderRailHeader = vi.fn((context: { collapsed: boolean; defaultTitle: string }) => {
1437
+ seen.push(context);
1438
+ // Unlabelled content: the region's name must still come from the h2.
1439
+ return context.collapsed ? null : document.createElement("img");
1440
+ });
1441
+ const { root, handle } = mount({ presentation: "rail", renderRailHeader });
1442
+ await flush();
1443
+
1444
+ const group = root.querySelector<HTMLElement>(
1445
+ ".persona-history-heading-group"
1446
+ )!;
1447
+ const title = root.querySelector<HTMLElement>(".persona-history-title")!;
1448
+ expect(seen).toEqual([{ collapsed: false, defaultTitle: "Messages" }]);
1449
+ expect(group.querySelector("img")).not.toBeNull();
1450
+ expect(title.classList.contains("persona-history-sr-only")).toBe(true);
1451
+ // aria-labelledby still resolves to a non-empty accessible name.
1452
+ const labelledBy = root.getAttribute("aria-labelledby")!;
1453
+ expect(document.getElementById(labelledBy)?.textContent).toBe("Messages");
1454
+
1455
+ // Collapsed re-invokes the slot; null empties the identity area.
1456
+ handle.setCollapsed(true);
1457
+ expect(seen[1]).toEqual({ collapsed: true, defaultTitle: "Messages" });
1458
+ expect(group.querySelector("img")).toBeNull();
1459
+ expect(title.classList.contains("persona-history-sr-only")).toBe(true);
1460
+
1461
+ // Panel ignores the slot: the plain title comes back, visible.
1462
+ handle.setPresentation("panel");
1463
+ await flush();
1464
+ expect(renderRailHeader).toHaveBeenCalledTimes(2);
1465
+ expect(title.classList.contains("persona-history-sr-only")).toBe(false);
1466
+ expect(group.querySelector("img")).toBeNull();
1467
+ });
1468
+
1469
+ it("falls back to the plain title when the rail brand renderer throws", async () => {
1470
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
1471
+ const renderRailHeader = vi.fn(() => {
1472
+ throw new Error("brand blew up");
1473
+ });
1474
+ const { root, handle } = mount({ presentation: "rail", renderRailHeader });
1475
+ await flush();
1476
+ const title = root.querySelector<HTMLElement>(".persona-history-title")!;
1477
+ expect(title.textContent).toBe("Messages");
1478
+ expect(title.classList.contains("persona-history-sr-only")).toBe(false);
1479
+ expect(warn).toHaveBeenCalledTimes(1);
1480
+
1481
+ // Warned once, not on every collapse.
1482
+ handle.setCollapsed(true);
1483
+ expect(renderRailHeader).toHaveBeenCalledTimes(2);
1484
+ expect(warn).toHaveBeenCalledTimes(1);
1485
+ warn.mockRestore();
1486
+ });
1487
+
1488
+ it("composes a brand declaration into the heading and the collapsed toggle", async () => {
1489
+ const railBrand = vi.fn((collapsed: boolean) => {
1490
+ const mark = document.createElement("i");
1491
+ mark.dataset.face = collapsed ? "collapsed" : "expanded";
1492
+ return mark;
1493
+ });
1494
+ const { root, handle } = mount({ presentation: "rail", railBrand });
1495
+ await flush();
1496
+
1497
+ // Expanded heading: the mark leads, the view title follows as wordmark.
1498
+ const brand = root.querySelector<HTMLElement>(
1499
+ ".persona-history-heading-brand"
1500
+ )!;
1501
+ expect(brand.getAttribute("aria-hidden")).toBe("true");
1502
+ expect(
1503
+ brand.querySelector<HTMLElement>(".persona-history-brand-mark i")?.dataset
1504
+ .face
1505
+ ).toBe("expanded");
1506
+ expect(brand.querySelector(".persona-history-wordmark")?.textContent).toBe(
1507
+ "Messages"
1508
+ );
1509
+ // The heading stays for the region's accessible name.
1510
+ const title = root.querySelector<HTMLElement>(".persona-history-title")!;
1511
+ expect(title.classList.contains("persona-history-sr-only")).toBe(true);
1512
+ expect(
1513
+ document.getElementById(root.getAttribute("aria-labelledby")!)?.textContent
1514
+ ).toBe("Messages");
1515
+
1516
+ // Collapsed rest face: the mark sits in the toggle beside its glyph.
1517
+ const toggle = root.querySelector<HTMLElement>(
1518
+ '[data-persona-history-focus="collapse"]'
1519
+ )!;
1520
+ expect(toggle.classList.contains("persona-history-back--branded")).toBe(true);
1521
+ const face = () =>
1522
+ toggle.querySelector<HTMLElement>(".persona-history-toggle-brand i")
1523
+ ?.dataset.face;
1524
+ expect(face()).toBe("collapsed");
1525
+ expect(toggle.querySelector("svg")).not.toBeNull();
1526
+
1527
+ // One call per face: neither ctx changes with the collapsed state.
1528
+ handle.setCollapsed(true);
1529
+ handle.setCollapsed(false);
1530
+ expect(face()).toBe("collapsed");
1531
+ expect(toggle.querySelector("svg")).not.toBeNull();
1532
+ expect(root.querySelector(".persona-history-heading-brand")).not.toBeNull();
1533
+ expect(railBrand).toHaveBeenCalledTimes(2);
1534
+ });
1535
+
1536
+ it("keeps the rail brand out of the panel and lets renderHeader outrank it", async () => {
1537
+ const railBrand = vi.fn(() => document.createElement("i"));
1538
+ const renderRailHeader = vi.fn(() => {
1539
+ const slot = document.createElement("b");
1540
+ slot.dataset.slot = "";
1541
+ return slot;
1542
+ });
1543
+ const { root, handle } = mount({
1544
+ presentation: "rail",
1545
+ railBrand,
1546
+ renderRailHeader,
1547
+ });
1548
+ await flush();
1549
+ const group = root.querySelector<HTMLElement>(
1550
+ ".persona-history-heading-group"
1551
+ )!;
1552
+ // Full override: the heading area is the slot's, not the brand default.
1553
+ expect(group.querySelector("[data-slot]")).not.toBeNull();
1554
+ expect(group.querySelector(".persona-history-heading-brand")).toBeNull();
1555
+ // The collapsed face is the brand's alone, so it is still there.
1556
+ const toggle = root.querySelector<HTMLElement>(
1557
+ '[data-persona-history-focus="collapse"]'
1558
+ )!;
1559
+ expect(toggle.querySelector(".persona-history-toggle-brand")).not.toBeNull();
1560
+
1561
+ handle.setPresentation("panel");
1562
+ await flush();
1563
+ expect(root.querySelector(".persona-history-toggle-brand")).toBeNull();
1564
+ expect(root.querySelector(".persona-history-heading-brand")).toBeNull();
1565
+ expect(
1566
+ root
1567
+ .querySelector(".persona-history-back")
1568
+ ?.classList.contains("persona-history-back--branded")
1569
+ ).toBe(false);
1570
+
1571
+ // Back in the rail the same face returns without a second brand call.
1572
+ handle.setPresentation("rail");
1573
+ await flush();
1574
+ expect(root.querySelector(".persona-history-toggle-brand")).not.toBeNull();
1575
+ expect(railBrand).toHaveBeenCalledTimes(1);
1576
+ });
1577
+
1578
+ it("leaves the toggle glyph alone when no brand is configured", async () => {
1579
+ const { root } = mount({ presentation: "rail" });
1580
+ await flush();
1581
+ const toggle = root.querySelector<HTMLElement>(
1582
+ '[data-persona-history-focus="collapse"]'
1583
+ )!;
1584
+ expect(toggle.querySelector(".persona-history-toggle-brand")).toBeNull();
1585
+ expect(toggle.classList.contains("persona-history-back--branded")).toBe(
1586
+ false
1587
+ );
1588
+ });
1589
+
1590
+ it("swaps the collapsed toggle's brand face for the glyph on hover and focus", async () => {
1591
+ mount({ presentation: "rail", railBrand: () => document.createElement("i") });
1592
+ await flush();
1593
+ const css = injectedHistoryCss();
1594
+ const branded =
1595
+ ".persona-history-view--rail-collapsed .persona-history-back--branded";
1596
+ // At rest the mark shows and the glyph waits under it.
1597
+ expect(css).toContain(
1598
+ `${branded} .persona-history-toggle-brand {\n display: flex;\n}`
1599
+ );
1600
+ expect(css).toContain(`${branded} > svg {\n display: none;\n}`);
1601
+ expect(css).toContain(
1602
+ `${branded}:hover .persona-history-toggle-brand,\n${branded}:focus-visible .persona-history-toggle-brand {\n display: none;\n}`
1603
+ );
1604
+ expect(css).toContain(
1605
+ `${branded}:hover > svg,\n${branded}:focus-visible > svg {\n display: block;\n}`
1606
+ );
1607
+ // Touch has no hover, so the glyph is the only face there.
1608
+ const coarse = css.slice(
1609
+ css.indexOf("@media (pointer: coarse) {\n .persona-history-view--rail-collapsed")
1610
+ );
1611
+ expect(coarse).toContain(
1612
+ ` ${branded} .persona-history-toggle-brand {\n display: none;\n }`
1613
+ );
1614
+ expect(coarse).toContain(` ${branded} > svg {\n display: block;\n }`);
1615
+ });
1616
+
1617
+ it("runs the primary new-conversation action from a pill pinned below the list", async () => {
1618
+ const { root, onStartNew } = mount();
1619
+ await flush();
1620
+ const primary = root.querySelector<HTMLButtonElement>(".persona-history-new");
1621
+ expect(primary?.textContent).toContain("New conversation");
1622
+ // DOM order is unchanged; flex order and sticky do the placement.
1623
+ expect(
1624
+ primary?.compareDocumentPosition(
1625
+ root.querySelector(".persona-history-list-region") as Node
1626
+ )
1627
+ ).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
1628
+ const css = injectedHistoryCss();
1629
+ const pill = css.slice(
1630
+ css.indexOf(".persona-history-view--panel button.persona-history-new {")
1631
+ );
1632
+ const block = pill.slice(0, pill.indexOf("}"));
1633
+ expect(block).toContain("order: 1;");
1634
+ expect(block).toContain("position: sticky;");
1635
+ expect(block).toContain("align-self: center;");
1636
+ expect(block).toContain("width: auto;");
1637
+
1638
+ primary?.click();
1639
+ await flush();
1640
+ expect(onStartNew).toHaveBeenCalledTimes(1);
1641
+ });
1642
+
1643
+ it("keeps the destructive actions behind the caption row overflow trigger", async () => {
1644
+ const { root } = mount();
1645
+ await flush();
1646
+
1647
+ // Nothing destructive stands in the resting surface.
1648
+ expect(root.querySelector(".persona-history-destructive")).toBeNull();
1649
+
1650
+ const caption = root.querySelector<HTMLElement>(".persona-history-caption")!;
1651
+ const trigger = listOptions(root)!;
1652
+ // Caption text leads, the trigger trails it, and the row sits above the list.
1653
+ expect(trigger.parentElement).toBe(caption);
1654
+ expect(
1655
+ caption
1656
+ .querySelector(".persona-history-scope")
1657
+ ?.compareDocumentPosition(trigger)
1658
+ ).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
1659
+ expect(
1660
+ caption.compareDocumentPosition(
1661
+ root.querySelector(".persona-history-list-region") as Node
1662
+ )
1663
+ ).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
1664
+
1665
+ expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
1666
+ expect(trigger.getAttribute("aria-label")).toBe("Conversation options");
1667
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
1668
+
1669
+ const [first] = openListMenu(root);
1670
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
1671
+ expect(
1672
+ root
1673
+ .querySelector(".persona-history-caption .persona-history-menu")
1674
+ ?.getAttribute("aria-label")
1675
+ ).toBe("Conversation options");
1676
+ // Opening lands on the first item; Escape closes and hands focus back.
1677
+ expect(document.activeElement).toBe(first);
1678
+ first.dispatchEvent(
1679
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
1680
+ );
1681
+ expect(root.querySelector(".persona-history-caption .persona-history-menu")).toBeNull();
1682
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
1683
+ expect(document.activeElement).toBe(trigger);
1684
+ });
1685
+
1686
+ it("takes the overflow trigger label from copy", async () => {
1687
+ const { root } = mount({ copy: { listOptionsLabel: "More options" } });
1688
+ await flush();
1689
+ expect(listOptions(root)?.getAttribute("aria-label")).toBe("More options");
1690
+ openListMenu(root);
1691
+ expect(
1692
+ root
1693
+ .querySelector(".persona-history-caption .persona-history-menu")
1694
+ ?.getAttribute("aria-label")
1695
+ ).toBe("More options");
1696
+ });
1697
+
1698
+ it("clears the list after the shell confirms delete-all", async () => {
1699
+ const { root, onRequestClearHistory } = mount();
1700
+ await flush();
1701
+
1702
+ const [clear] = openListMenu(root);
1703
+ expect(clear.textContent).toBe("Delete all conversations");
1704
+ clear.click();
1705
+ await flush();
1706
+
1707
+ expect(onRequestClearHistory).toHaveBeenCalledTimes(1);
1708
+ expect(rows(root)).toHaveLength(0);
1709
+ expect(stateBlock(root)?.dataset.personaHistoryState).toBe("empty");
1710
+ expect(liveText(root)).toBe("All conversations were deleted.");
1711
+ // The menu closed with the action, and now has nothing left to offer.
1712
+ expect(root.querySelector(".persona-history-caption .persona-history-menu")).toBeNull();
1713
+ expect(listOptions(root)?.hidden).toBe(true);
1714
+ });
1715
+
1716
+ it("omits forget-this-device when the provider cannot reset", async () => {
1717
+ const { root } = mount();
1718
+ await flush();
1719
+ expect(openListMenu(root).map((item) => item.textContent)).toEqual([
1720
+ "Delete all conversations",
1721
+ ]);
1722
+ });
1723
+
1724
+ it("offers forget-this-device when the provider can reset", async () => {
1725
+ const base = createDemoHistoryProvider({
1726
+ conversations: DEFAULT_SEEDS,
1727
+ now: () => NOW,
1728
+ });
1729
+ const provider: HistoryProvider = {
1730
+ ...base,
1731
+ resetDevice: async () => ({ remoteRevocationConfirmed: false }),
1732
+ };
1733
+ const { root, onRequestResetIdentity } = mount({ provider });
1734
+ await flush();
1735
+
1736
+ const items = openListMenu(root);
1737
+ expect(items.map((item) => item.textContent)).toEqual([
1738
+ "Delete all conversations",
1739
+ "Forget this device",
1740
+ ]);
1741
+ items[1].click();
1742
+ await flush();
1743
+ expect(onRequestResetIdentity).toHaveBeenCalledTimes(1);
1744
+ expect(liveText(root)).toBe("This device was forgotten.");
1745
+ });
1746
+
1747
+ it("renders host list actions above the built-ins, neutral unless danger", async () => {
1748
+ const onExport = vi.fn();
1749
+ const { root } = mount({
1750
+ listActions: [
1751
+ { id: "export", label: "Export conversations", onSelect: onExport },
1752
+ ],
1753
+ });
1754
+ await flush();
1755
+
1756
+ const items = openListMenu(root);
1757
+ expect(items.map((item) => item.textContent)).toEqual([
1758
+ "Export conversations",
1759
+ "Delete all conversations",
1760
+ ]);
1761
+ expect(items[0].classList.contains("persona-history-menu-item--danger")).toBe(false);
1762
+ expect(items[1].classList.contains("persona-history-menu-item--danger")).toBe(true);
1763
+
1764
+ items[0].click();
1765
+ await flush();
1766
+ expect(onExport).toHaveBeenCalledTimes(1);
1767
+ const context = onExport.mock.calls[0][0] as {
1768
+ conversations: { id: string }[];
1769
+ };
1770
+ expect(context.conversations.map((c) => c.id)).toEqual(
1771
+ DEFAULT_SEEDS.map((seed) => seed.id)
1772
+ );
1773
+ // The action closed the menu like the built-ins do.
1774
+ expect(root.querySelector(".persona-history-caption .persona-history-menu")).toBeNull();
1775
+ });
1776
+
1777
+ it("keeps the overflow trigger for host actions when delete-all is hidden", async () => {
1778
+ const onExport = vi.fn();
1779
+ const { root } = mount({
1780
+ showDeleteAll: false,
1781
+ listActions: [
1782
+ { id: "export", label: "Export conversations", onSelect: onExport },
1783
+ ],
1784
+ });
1785
+ await flush();
1786
+
1787
+ expect(openListMenu(root).map((item) => item.textContent)).toEqual([
1788
+ "Export conversations",
1789
+ ]);
1790
+ });
1791
+
1792
+ it("styles a danger host action like the built-in destructive items", async () => {
1793
+ const { root } = mount({
1794
+ listActions: [
1795
+ { id: "purge", label: "Purge cache", danger: true, onSelect: vi.fn() },
1796
+ ],
1797
+ });
1798
+ await flush();
1799
+ const [purge] = openListMenu(root);
1800
+ expect(purge.classList.contains("persona-history-menu-item--danger")).toBe(true);
1801
+ });
1802
+
1803
+ it("keeps forget-this-device reachable on an empty list", async () => {
1804
+ const base = createDemoHistoryProvider({
1805
+ conversations: [],
1806
+ now: () => NOW,
1807
+ });
1808
+ const provider: HistoryProvider = {
1809
+ ...base,
1810
+ resetDevice: async () => ({ remoteRevocationConfirmed: false }),
1811
+ };
1812
+ const { root } = mount({ provider });
1813
+ await flush();
1814
+
1815
+ expect(stateBlock(root)?.dataset.personaHistoryState).toBe("empty");
1816
+ expect(listOptions(root)?.hidden).toBe(false);
1817
+ // Delete-all drops out with the rows it acted on; the reset stays.
1818
+ expect(openListMenu(root).map((item) => item.textContent)).toEqual([
1819
+ "Forget this device",
1820
+ ]);
1821
+ });
1822
+ });
1823
+
1824
+ describe("history view rail sections", () => {
1825
+ const svgIcon = (): SVGElement =>
1826
+ document.createElementNS("http://www.w3.org/2000/svg", "svg");
1827
+
1828
+ const sections = (): HistoryViewOptions["railSections"] => [
1829
+ {
1830
+ id: "workspace",
1831
+ title: "Workspace",
1832
+ placement: "above-conversations",
1833
+ items: [
1834
+ { id: "projects", label: "Projects", iconNode: () => svgIcon(), onSelect: vi.fn() },
1835
+ { id: "drafts", label: "Drafts", badge: "3", onSelect: vi.fn() },
1836
+ ],
1837
+ },
1838
+ {
1839
+ id: "tools",
1840
+ placement: "below-conversations",
1841
+ items: [{ id: "export", label: "Export", onSelect: vi.fn() }],
1842
+ },
1843
+ {
1844
+ id: "account",
1845
+ placement: "footer",
1846
+ items: [{ id: "settings", label: "Settings", onSelect: vi.fn() }],
1847
+ },
1848
+ ];
1849
+
1850
+ const sectionAt = (root: HTMLElement, id: string): HTMLElement | null =>
1851
+ root.querySelector<HTMLElement>(`[data-persona-rail-section="${id}"]`);
1852
+
1853
+ it("stacks the sections around the conversation list in placement order", async () => {
1854
+ const railSections = sections();
1855
+ const { root } = mount({ presentation: "rail", railSections });
1856
+ await flush();
1857
+
1858
+ const body = root.querySelector<HTMLElement>(".persona-history-body")!;
1859
+ const order = Array.from(body.children).map(
1860
+ (child) =>
1861
+ child.getAttribute("data-persona-rail-section") ?? child.className
1862
+ );
1863
+ expect(order).toEqual([
1864
+ "persona-history-scope-alert",
1865
+ "persona-history-new",
1866
+ "workspace",
1867
+ "persona-history-caption",
1868
+ "persona-history-list-region",
1869
+ "tools",
1870
+ "account",
1871
+ ]);
1872
+ // The footer bucket sinks to the bottom of the body.
1873
+ expect(sectionAt(root, "account")!.className).toContain(
1874
+ "persona-history-nav--footer"
1875
+ );
1876
+ // A titled section is a labelled group; the heading reuses the group treatment.
1877
+ const workspace = sectionAt(root, "workspace")!;
1878
+ const heading = workspace.querySelector(".persona-history-group-heading")!;
1879
+ expect(heading.textContent).toBe("Workspace");
1880
+ expect(workspace.getAttribute("aria-labelledby")).toBe(heading.id);
1881
+ expect(sectionAt(root, "tools")!.getAttribute("aria-label")).toBe("tools");
1882
+ });
1883
+
1884
+ it("builds each item from its icon thunk, label and badge", async () => {
1885
+ const railSections = sections();
1886
+ const { root } = mount({ presentation: "rail", railSections });
1887
+ await flush();
1888
+
1889
+ const projects = root.querySelector<HTMLButtonElement>(
1890
+ '[data-persona-rail-item="projects"]'
1891
+ )!;
1892
+ expect(projects.type).toBe("button");
1893
+ expect(projects.getAttribute("aria-label")).toBe("Projects");
1894
+ expect(projects.querySelector(".persona-history-nav-icon svg")).not.toBeNull();
1895
+ expect(
1896
+ projects.querySelector(".persona-history-nav-label")?.textContent
1897
+ ).toBe("Projects");
1898
+ expect(projects.querySelector(".persona-history-nav-badge")).toBeNull();
1899
+
1900
+ // No icon thunk: label only, and no collapsed-square opt-in.
1901
+ const drafts = root.querySelector<HTMLButtonElement>(
1902
+ '[data-persona-rail-item="drafts"]'
1903
+ )!;
1904
+ expect(drafts.querySelector(".persona-history-nav-icon")).toBeNull();
1905
+ expect(drafts.classList.contains("persona-history-nav-item--icon")).toBe(
1906
+ false
1907
+ );
1908
+ expect(drafts.querySelector(".persona-history-nav-badge")?.textContent).toBe(
1909
+ "3"
1910
+ );
1911
+
1912
+ drafts.click();
1913
+ expect(railSections![0].items[1].onSelect).toHaveBeenCalledTimes(1);
1914
+ });
1915
+
1916
+ it("renders label-only when the icon thunk yields null", async () => {
1917
+ const { root } = mount({
1918
+ presentation: "rail",
1919
+ railSections: [
1920
+ {
1921
+ id: "workspace",
1922
+ placement: "above-conversations",
1923
+ items: [{ id: "projects", label: "Projects", iconNode: () => null, onSelect: vi.fn() }],
1924
+ },
1925
+ ],
1926
+ });
1927
+ await flush();
1928
+ const projects = root.querySelector<HTMLButtonElement>(
1929
+ '[data-persona-rail-item="projects"]'
1930
+ )!;
1931
+ expect(projects.querySelector(".persona-history-nav-icon")).toBeNull();
1932
+ expect(projects.textContent).toBe("Projects");
1933
+ });
1934
+
1935
+ it("keeps the sections rail-only across a presentation flip", async () => {
1936
+ const railSections = sections();
1937
+ const { root, handle } = mount({ presentation: "panel", railSections });
1938
+ await flush();
1939
+ expect(root.querySelector("[data-persona-rail-section]")).toBeNull();
1940
+
1941
+ handle.setPresentation("rail");
1942
+ await flush();
1943
+ expect(root.querySelectorAll("[data-persona-rail-section]")).toHaveLength(3);
1944
+
1945
+ handle.setPresentation("panel");
1946
+ await flush();
1947
+ expect(root.querySelector("[data-persona-rail-section]")).toBeNull();
1948
+
1949
+ // Back into rail re-attaches the same nodes rather than rebuilding them.
1950
+ handle.setPresentation("rail");
1951
+ await flush();
1952
+ expect(root.querySelectorAll("[data-persona-rail-section]")).toHaveLength(3);
1953
+ });
1954
+
1955
+ it("keeps only the icon rows as squares in the collapsed rail", async () => {
1956
+ mount({ presentation: "rail", collapsed: true, railSections: sections() });
1957
+ await flush();
1958
+ const css = injectedHistoryCss();
1959
+ // Sections survive the body-child sweep that hides everything else.
1960
+ expect(css).toContain(
1961
+ ".persona-history-body > :not(.persona-history-new):not(.persona-history-nav)"
1962
+ );
1963
+ const hidden = css.slice(
1964
+ css.indexOf(
1965
+ ".persona-history-view--rail-collapsed .persona-history-nav .persona-history-group-heading"
1966
+ )
1967
+ );
1968
+ const hiddenBlock = hidden.slice(0, hidden.indexOf("}"));
1969
+ // Headings, labels, badges and every row without an icon go.
1970
+ expect(hiddenBlock).toContain(
1971
+ "button.persona-history-nav-item:not(.persona-history-nav-item--icon)"
1972
+ );
1973
+ expect(hiddenBlock).toContain(".persona-history-nav-label");
1974
+ expect(hiddenBlock).toContain(".persona-history-nav-badge");
1975
+ expect(hiddenBlock).toContain("display: none;");
1976
+
1977
+ const square = css.slice(
1978
+ css.indexOf(
1979
+ ".persona-history-view--rail-collapsed button.persona-history-nav-item {"
1980
+ )
1981
+ );
1982
+ const squareBlock = square.slice(0, square.indexOf("}"));
1983
+ expect(squareBlock).toContain("width: 36px;");
1984
+ expect(squareBlock).toContain("margin: 0 auto;");
1985
+ });
1986
+
1987
+ // Render-backed sections: the plugin seam the core normalizes into the same
1988
+ // array, carrying a `render` in place of items.
1989
+ it("mounts render-backed content under its heading and re-renders on collapse", async () => {
1990
+ const render = vi.fn((collapsed: boolean) => {
1991
+ const node = document.createElement("p");
1992
+ node.setAttribute("data-plugin", collapsed ? "collapsed" : "expanded");
1993
+ return node;
1994
+ });
1995
+ const { root, handle } = mount({
1996
+ presentation: "rail",
1997
+ railSections: [
1998
+ ...sections()!,
1999
+ {
2000
+ id: "pinned",
2001
+ title: "Pinned",
2002
+ placement: "above-conversations",
2003
+ items: [],
2004
+ render,
2005
+ },
2006
+ ],
2007
+ });
2008
+ await flush();
2009
+
2010
+ const pinned = sectionAt(root, "pinned")!;
2011
+ // Config sections keep their array order ahead of it inside the bucket.
2012
+ expect(pinned.previousElementSibling).toBe(sectionAt(root, "workspace"));
2013
+ expect(pinned.querySelector(".persona-history-group-heading")?.textContent).toBe(
2014
+ "Pinned"
2015
+ );
2016
+ expect(pinned.querySelector("[data-plugin]")?.getAttribute("data-plugin")).toBe(
2017
+ "expanded"
2018
+ );
2019
+ expect(render).toHaveBeenCalledTimes(1);
2020
+
2021
+ handle.setCollapsed(true);
2022
+ await flush();
2023
+ expect(render).toHaveBeenCalledTimes(2);
2024
+ expect(render).toHaveBeenLastCalledWith(true);
2025
+ // The previous content is replaced, not appended beside.
2026
+ expect(pinned.querySelectorAll("[data-plugin]")).toHaveLength(1);
2027
+ expect(pinned.querySelector("[data-plugin]")?.getAttribute("data-plugin")).toBe(
2028
+ "collapsed"
2029
+ );
2030
+ expect(pinned.querySelector(".persona-history-group-heading")).not.toBeNull();
2031
+ });
2032
+
2033
+ it("hides the section when render yields null", async () => {
2034
+ const { root, handle } = mount({
2035
+ presentation: "rail",
2036
+ railSections: [
2037
+ {
2038
+ id: "pinned",
2039
+ title: "Pinned",
2040
+ placement: "above-conversations",
2041
+ items: [],
2042
+ render: (collapsed) => {
2043
+ if (collapsed) return null;
2044
+ return document.createElement("p");
2045
+ },
2046
+ },
2047
+ ],
2048
+ });
2049
+ await flush();
2050
+ const pinned = sectionAt(root, "pinned")!;
2051
+ expect(pinned.hidden).toBe(false);
2052
+
2053
+ handle.setCollapsed(true);
2054
+ await flush();
2055
+ expect(pinned.hidden).toBe(true);
2056
+ expect(pinned.querySelector("p")).toBeNull();
2057
+ // Hiding beats the section's own display rule.
2058
+ expect(injectedHistoryCss()).toContain(
2059
+ ".persona-history-view .persona-history-nav[hidden]"
2060
+ );
2061
+ });
2062
+
2063
+ it("warns once for a throwing render and empties only that section", async () => {
2064
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
2065
+ const { root, handle } = mount({
2066
+ presentation: "rail",
2067
+ railSections: [
2068
+ {
2069
+ id: "pinned",
2070
+ placement: "above-conversations",
2071
+ items: [],
2072
+ render: () => {
2073
+ throw new Error("section exploded");
2074
+ },
2075
+ },
2076
+ {
2077
+ id: "tools",
2078
+ placement: "below-conversations",
2079
+ items: [],
2080
+ render: () => document.createElement("p"),
2081
+ },
2082
+ ],
2083
+ });
2084
+ await flush();
2085
+ const pinned = sectionAt(root, "pinned")!;
2086
+ expect(pinned.hidden).toBe(true);
2087
+ expect(pinned.childElementCount).toBe(0);
2088
+ expect(sectionAt(root, "tools")!.hidden).toBe(false);
2089
+ expect(warn).toHaveBeenCalledTimes(1);
2090
+ expect(warn).toHaveBeenCalledWith(
2091
+ "[persona] history rail section threw",
2092
+ "pinned",
2093
+ expect.any(Error)
2094
+ );
2095
+
2096
+ // A collapse re-renders the survivor and never re-enters the dropped one.
2097
+ handle.setCollapsed(true);
2098
+ await flush();
2099
+ expect(pinned.hidden).toBe(true);
2100
+ expect(warn).toHaveBeenCalledTimes(1);
2101
+ warn.mockRestore();
2102
+ });
2103
+ });
2104
+
2105
+ describe("history view rail list header", () => {
2106
+ const heading = (root: HTMLElement): HTMLElement | null =>
2107
+ root.querySelector<HTMLElement>(".persona-history-conversations-title");
2108
+
2109
+ it("heads the rail list with the overflow trigger and the scope caption", async () => {
2110
+ const { root } = mount({ presentation: "rail" });
2111
+ await flush();
2112
+
2113
+ const caption = root.querySelector<HTMLElement>(".persona-history-caption")!;
2114
+ expect(caption.hidden).toBe(false);
2115
+ const title = heading(root)!;
2116
+ expect(title.tagName).toBe("H3");
2117
+ expect(title.textContent).toBe("Conversations");
2118
+ expect(title.parentElement).toBe(caption);
2119
+ expect(listOptions(root)?.parentElement).toBe(caption);
2120
+
2121
+ // Compact device caption: the icon leads the title, identity stamped so
2122
+ // the CSS can key the icon to the browser-only state.
2123
+ const scope = caption.querySelector<HTMLElement>(".persona-history-scope")!;
2124
+ expect(scope.dataset.personaHistoryIdentity).toBe("browser_only");
2125
+ expect(scope.querySelector(".persona-history-scope-icon svg")).toBeTruthy();
2126
+ expect(scope.querySelector(".persona-history-scope-title")?.textContent).toBe(
2127
+ "On this device"
2128
+ );
2129
+ });
2130
+
2131
+ it("keeps a hidden caption hidden despite the rail's flex display", async () => {
2132
+ const { root, provider } = mount({ presentation: "rail" });
2133
+ await flush();
2134
+ provider.setIdentityStatus({
2135
+ state: "authentication_required",
2136
+ reason: "invalid_identity_proof",
2137
+ });
2138
+ await flush();
2139
+ const scope = root.querySelector<HTMLElement>(".persona-history-scope")!;
2140
+ expect(scope.hidden).toBe(true);
2141
+ // The rail's display:flex outranks the UA hidden rule; the sheet restates it.
2142
+ expect(injectedHistoryCss()).toContain(
2143
+ ".persona-history-view--rail .persona-history-scope[hidden]"
2144
+ );
2145
+ });
2146
+
2147
+ it("pins themable typography on the list heading and the sub-headers", async () => {
2148
+ mount({ presentation: "rail" });
2149
+ await flush();
2150
+ const css = injectedHistoryCss();
2151
+ // Host pages style bare h2/h3, which beats inheritance for any property
2152
+ // these rules leave undeclared; each must pin its full text style behind
2153
+ // its component token.
2154
+ const blockOf = (selector: string): string => {
2155
+ const start = css.indexOf(`${selector} {`);
2156
+ expect(start).toBeGreaterThan(-1);
2157
+ return css.slice(start, css.indexOf("}", start));
2158
+ };
2159
+ const heading = blockOf(
2160
+ ".persona-history-view .persona-history-conversations-title"
2161
+ );
2162
+ for (const key of ["fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "color"]) {
2163
+ expect(heading).toContain(`--persona-components-history-listHeading-${key}`);
2164
+ }
2165
+ const group = blockOf(".persona-history-view .persona-history-group-heading");
2166
+ for (const key of ["fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "color"]) {
2167
+ expect(group).toContain(`--persona-components-history-groupHeading-${key}`);
2168
+ }
2169
+ });
2170
+
2171
+ it("takes the heading text from copy", async () => {
2172
+ const { root } = mount({
2173
+ presentation: "rail",
2174
+ copy: { conversationsTitle: "Chats" },
2175
+ });
2176
+ await flush();
2177
+ expect(heading(root)?.textContent).toBe("Chats");
2178
+ });
2179
+
2180
+ it("keeps the rail heading with the scope caption and overflow trigger off", async () => {
2181
+ const { root } = mount({
2182
+ presentation: "rail",
2183
+ showScopeStatus: false,
2184
+ showDeleteAll: false,
2185
+ });
2186
+ await flush();
2187
+
2188
+ const caption = root.querySelector<HTMLElement>(".persona-history-caption")!;
2189
+ expect(caption.hidden).toBe(false);
2190
+ expect(heading(root)?.textContent).toBe("Conversations");
2191
+ expect(listOptions(root)).toBeNull();
2192
+ expect(caption.querySelector(".persona-history-scope")).toBeNull();
2193
+ });
2194
+
2195
+ it("hides the panel heading visually while keeping it in the tree", async () => {
2196
+ const { root } = mount();
2197
+ await flush();
2198
+ expect(heading(root)?.textContent).toBe("Conversations");
2199
+ expect(injectedHistoryCss()).toContain(
2200
+ ".persona-history-view--panel .persona-history-conversations-title"
2201
+ );
2202
+ });
2203
+
2204
+ it("draws the divider only with a nav block above the conversations", async () => {
2205
+ const railSections: HistoryViewOptions["railSections"] = [
2206
+ {
2207
+ id: "workspace",
2208
+ title: "Workspace",
2209
+ placement: "above-conversations",
2210
+ items: [{ id: "projects", label: "Projects", onSelect: vi.fn() }],
2211
+ },
2212
+ ];
2213
+ const withNav = mount({ presentation: "rail", railSections });
2214
+ const withoutNav = mount({ presentation: "rail" });
2215
+ const footerOnly = mount({
2216
+ presentation: "rail",
2217
+ railSections: [
2218
+ {
2219
+ id: "account",
2220
+ placement: "footer",
2221
+ items: [{ id: "settings", label: "Settings", onSelect: vi.fn() }],
2222
+ },
2223
+ ],
2224
+ });
2225
+ await flush();
2226
+
2227
+ expect(withNav.root.classList.contains("persona-history-view--has-nav")).toBe(true);
2228
+ expect(withoutNav.root.classList.contains("persona-history-view--has-nav")).toBe(false);
2229
+ expect(footerOnly.root.classList.contains("persona-history-view--has-nav")).toBe(false);
2230
+
2231
+ // The divider is rail chrome; a move into panel takes it away and a move
2232
+ // back restores it.
2233
+ withNav.handle.setPresentation("panel");
2234
+ expect(withNav.root.classList.contains("persona-history-view--has-nav")).toBe(false);
2235
+ withNav.handle.setPresentation("rail");
2236
+ expect(withNav.root.classList.contains("persona-history-view--has-nav")).toBe(true);
2237
+ });
2238
+ });
2239
+
2240
+ describe("history view header placement", () => {
2241
+ const barOf = (root: HTMLElement): HTMLElement | null =>
2242
+ root.querySelector<HTMLElement>(".persona-history-topbar");
2243
+
2244
+ it("carries the bar inline by default and holds nothing but the three controls", async () => {
2245
+ const { root } = mount();
2246
+ await flush();
2247
+ const bar = barOf(root)!;
2248
+ expect(bar.parentElement).toBe(root);
2249
+ expect(bar.querySelector(".persona-history-scope")).toBeNull();
2250
+ expect(bar.querySelector(".persona-history-scope-alert")).toBeNull();
2251
+ expect(bar.querySelectorAll("button")).toHaveLength(2);
2252
+ });
2253
+
2254
+ it("leaves the bar out of the view element when the shell hosts it", async () => {
2255
+ const { root, handle } = mount({ headerPlacement: "external" });
2256
+ await flush();
2257
+ expect(barOf(root)).toBeNull();
2258
+ const bar = handle.getHeaderElement();
2259
+ expect(bar.classList.contains("persona-history-topbar")).toBe(true);
2260
+ expect(bar.isConnected).toBe(false);
2261
+
2262
+ // The shell mounts it wherever it likes; the view never reclaims it.
2263
+ const host = document.createElement("div");
2264
+ document.body.appendChild(host);
2265
+ host.appendChild(bar);
2266
+ handle.refresh();
2267
+ await flush();
2268
+ expect(bar.parentElement).toBe(host);
2269
+ host.remove();
2270
+ });
2271
+
2272
+ it("moves the bar between the shell and the view without replaying the entrance", async () => {
2273
+ const { root, handle } = mount();
2274
+ await flush();
2275
+ const bar = handle.getHeaderElement();
2276
+
2277
+ handle.setHeaderPlacement("external");
2278
+ expect(bar.parentElement).toBeNull();
2279
+ expect(bar.classList.contains("persona-history-topbar--shell")).toBe(true);
2280
+ // Idempotent: a repeat call must not disturb the shell's own hosting.
2281
+ const host = document.createElement("div");
2282
+ host.appendChild(bar);
2283
+ handle.setHeaderPlacement("external");
2284
+ expect(bar.parentElement).toBe(host);
2285
+
2286
+ handle.setHeaderPlacement("inline");
2287
+ expect(bar.parentElement).toBe(root);
2288
+ expect(bar.nextElementSibling?.classList.contains("persona-history-body")).toBe(
2289
+ true
2290
+ );
2291
+ expect(bar.classList.contains("persona-history-topbar--shell")).toBe(false);
2292
+ expect(root.classList.contains("persona-history-view--enter")).toBe(true);
2293
+ handle.setHeaderPlacement("inline");
2294
+ expect(root.querySelectorAll(".persona-history-topbar")).toHaveLength(1);
2295
+ });
2296
+
2297
+ it("keeps a header slot replacement in the shell host that already holds it", async () => {
2298
+ let custom: HTMLElement | null = null;
2299
+ const { handle, provider } = mount({
2300
+ headerPlacement: "external",
2301
+ slots: {
2302
+ header: ({ identityStatus, defaultRenderer }) => {
2303
+ if (identityStatus.state !== "verified") return defaultRenderer();
2304
+ custom = document.createElement("div");
2305
+ custom.setAttribute("data-test-header", "");
2306
+ return custom;
2307
+ },
2308
+ },
2309
+ });
2310
+ await flush();
2311
+ const host = document.createElement("div");
2312
+ document.body.appendChild(host);
2313
+ host.appendChild(handle.getHeaderElement());
2314
+
2315
+ provider.setIdentityStatus({ state: "verified" });
2316
+ await flush();
2317
+ expect(custom).not.toBeNull();
2318
+ expect(handle.getHeaderElement()).toBe(custom);
2319
+ expect(custom!.parentElement).toBe(host);
2320
+ host.remove();
2321
+ });
2322
+
2323
+ it("removes the bar on destroy wherever it is hosted", async () => {
2324
+ const record = mount({ headerPlacement: "external" });
2325
+ await flush();
2326
+ const host = document.createElement("div");
2327
+ document.body.appendChild(host);
2328
+ host.appendChild(record.handle.getHeaderElement());
2329
+
2330
+ record.handle.destroy();
2331
+ mounted.splice(mounted.indexOf(record), 1);
2332
+ expect(host.childNodes).toHaveLength(0);
2333
+ host.remove();
2334
+ });
2335
+ });
2336
+
2337
+ describe("history view unsupported affordances and lifecycle", () => {
2338
+ it("attaches the shell's tooltip to the bar controls and destroys it with the view", async () => {
2339
+ const destroy = vi.fn();
2340
+ const attachTooltip = vi.fn(
2341
+ (opts: { anchor: HTMLElement; text: string | (() => string) }) => {
2342
+ void opts;
2343
+ return {
2344
+ isOpen: false,
2345
+ show: () => {},
2346
+ hide: () => {},
2347
+ reposition: () => {},
2348
+ destroy,
2349
+ };
2350
+ }
2351
+ );
2352
+ const record = mount({ attachTooltip });
2353
+ await flush();
2354
+ expect(attachTooltip).toHaveBeenCalledTimes(2);
2355
+ const anchors = attachTooltip.mock.calls.map(([opts]) => opts.anchor);
2356
+ expect(anchors).toContain(record.root.querySelector(".persona-history-back"));
2357
+ expect(anchors).toContain(record.root.querySelector(".persona-history-new-icon"));
2358
+ // Live getter: a relabel (rail close) changes the tooltip text too.
2359
+ const backCall = attachTooltip.mock.calls.find(
2360
+ ([opts]) => opts.anchor === record.root.querySelector(".persona-history-back")
2361
+ )!;
2362
+ record.handle.setPresentation("rail");
2363
+ const text = backCall[0].text;
2364
+ expect(typeof text === "function" ? text() : text).toBe(
2365
+ "Collapse conversation list"
2366
+ );
2367
+ record.handle.destroy();
2368
+ expect(destroy).toHaveBeenCalledTimes(2);
2369
+ });
2370
+
2371
+ it("renders no search, archive, unread, or delivery affordances", async () => {
2372
+ const { root } = mount();
2373
+ await flush();
2374
+ expect(root.querySelector("input")).toBeNull();
2375
+ expect(root.querySelector('[type="search"]')).toBeNull();
2376
+ expect(root.innerHTML).not.toMatch(/archive|unread|delivered/i);
2377
+ });
2378
+
2379
+ it("is a labelled region with list semantics", async () => {
2380
+ const { root } = mount();
2381
+ await flush();
2382
+ expect(root.getAttribute("role")).toBe("region");
2383
+ const labelledBy = root.getAttribute("aria-labelledby");
2384
+ expect(root.querySelector(`#${labelledBy}`)?.textContent).toBe("Messages");
2385
+ expect(root.querySelectorAll("ul.persona-history-list").length).toBe(3);
2386
+ expect(root.querySelectorAll("li.persona-history-item").length).toBe(4);
2387
+ });
2388
+
2389
+ it("drops subscriptions and DOM on destroy", async () => {
2390
+ const record = mount();
2391
+ await flush();
2392
+ const { provider, root } = record;
2393
+
2394
+ record.handle.destroy();
2395
+ mounted.splice(mounted.indexOf(record), 1);
2396
+
2397
+ expect(root.isConnected).toBe(false);
2398
+ expect(root.childNodes).toHaveLength(0);
2399
+ // A late provider notification must not resurrect any DOM.
2400
+ provider.setIdentityStatus({ state: "verified" });
2401
+ expect(root.childNodes).toHaveLength(0);
2402
+ });
2403
+
2404
+ it("ignores a superseded list response", async () => {
2405
+ const { root, provider, handle } = mount();
2406
+ await flush();
2407
+ provider.setLatency(30);
2408
+ handle.refresh();
2409
+ provider.setLatency(0);
2410
+ handle.refresh();
2411
+ await new Promise((resolve) => setTimeout(resolve, 60));
2412
+ expect(rows(root)).toHaveLength(4);
2413
+ });
2414
+ });
2415
+
2416
+ /**
2417
+ * Entrance/exit motion. jsdom has no Web Animations API, so the fallback path
2418
+ * (no `element.animate`) is the default here and WAAPI is installed explicitly
2419
+ * for the animated path.
2420
+ */
2421
+ describe("history view entrance and exit motion", () => {
2422
+ /** Structural stand-ins: the DOM keyframe types are not eslint globals. */
2423
+ type Frame = Record<string, string | number>;
2424
+ type FakeAnimation = {
2425
+ target: Element;
2426
+ keyframes: Frame[];
2427
+ options: { duration?: number };
2428
+ finished: Promise<Animation>;
2429
+ cancelled: boolean;
2430
+ cancel(): void;
2431
+ settle(): void;
2432
+ };
2433
+
2434
+ const installWaapi = (): {
2435
+ animations: FakeAnimation[];
2436
+ restore: () => void;
2437
+ } => {
2438
+ const animations: FakeAnimation[] = [];
2439
+ const animate = function (
2440
+ this: Element,
2441
+ keyframes: Frame[],
2442
+ options: { duration?: number }
2443
+ ): Animation {
2444
+ let resolve!: () => void;
2445
+ let reject!: () => void;
2446
+ const finished = new Promise<Animation>((onDone, onFail) => {
2447
+ resolve = () => onDone(animation as unknown as Animation);
2448
+ reject = () => onFail(new Error("cancelled"));
2449
+ });
2450
+ const animation: FakeAnimation = {
2451
+ target: this,
2452
+ keyframes,
2453
+ options,
2454
+ finished,
2455
+ cancelled: false,
2456
+ cancel: () => {
2457
+ animation.cancelled = true;
2458
+ reject();
2459
+ },
2460
+ settle: resolve,
2461
+ };
2462
+ // Nothing observes an unsettled rejection until the view awaits it.
2463
+ finished.catch(() => undefined);
2464
+ animations.push(animation);
2465
+ return animation as unknown as Animation;
2466
+ } as unknown as Element["animate"];
2467
+ Element.prototype.animate = animate;
2468
+ return {
2469
+ animations,
2470
+ restore: () => {
2471
+ delete (Element.prototype as Partial<Element>).animate;
2472
+ },
2473
+ };
2474
+ };
2475
+
2476
+ const injectedCss = (): string =>
2477
+ document.querySelector(
2478
+ 'style[data-persona-plugin-style="persona-history-view"]'
2479
+ )?.textContent ?? "";
2480
+
2481
+ const rootOf = (animations: FakeAnimation[]): FakeAnimation =>
2482
+ animations.find((animation) =>
2483
+ (animation.target as HTMLElement).classList.contains("persona-history-view")
2484
+ )!;
2485
+ const bodyOf = (animations: FakeAnimation[]): FakeAnimation =>
2486
+ animations.find((animation) =>
2487
+ (animation.target as HTMLElement).classList.contains("persona-history-body")
2488
+ )!;
2489
+
2490
+ afterEach(() => {
2491
+ delete (Element.prototype as Partial<Element>).animate;
2492
+ });
2493
+
2494
+ it("enters by animating the body alone, never the root or the bar", async () => {
2495
+ const { root } = mount();
2496
+ await flush();
2497
+ expect(root.classList.contains("persona-history-view--enter")).toBe(true);
2498
+
2499
+ const css = injectedCss();
2500
+ // The bar is persistent chrome: only the region below it moves.
2501
+ expect(css).not.toContain("@keyframes persona-history-enter {");
2502
+ expect(css).not.toContain(".persona-history-view--enter {");
2503
+ expect(css).toContain(".persona-history-view--enter .persona-history-body");
2504
+ expect(css).toContain("translateX(var(--persona-history-slide))");
2505
+ // Each presentation slides from its own edge; the rail travels less.
2506
+ expect(css).toContain("--persona-history-slide: 20px;");
2507
+ expect(css).toContain("--persona-history-slide: 12px;");
2508
+ expect(css).not.toContain(".persona-history-view--enter .persona-history-topbar");
2509
+ });
2510
+
2511
+ it("fades the shell-hosted bar in once, without animating the bar chrome", async () => {
2512
+ const { handle } = mount({ headerPlacement: "external" });
2513
+ await flush();
2514
+ const bar = handle.getHeaderElement();
2515
+ expect(bar.classList.contains("persona-history-topbar--shell")).toBe(true);
2516
+
2517
+ const css = injectedCss();
2518
+ expect(css).toContain(
2519
+ ".persona-history-topbar--shell.persona-history-topbar--shell-enter"
2520
+ );
2521
+ const fade = css.slice(css.indexOf("@keyframes persona-history-header-fade"));
2522
+ expect(fade.slice(0, fade.indexOf("}"))).not.toContain("transform");
2523
+ });
2524
+
2525
+ it("drops the entrance before a host move so re-parenting cannot replay it", async () => {
2526
+ const { root, handle } = mount();
2527
+ await flush();
2528
+ expect(root.classList.contains("persona-history-view--enter")).toBe(true);
2529
+
2530
+ handle.setPresentation("rail");
2531
+ expect(root.classList.contains("persona-history-view--enter")).toBe(false);
2532
+ expect(root.classList.contains("persona-history-view--rail")).toBe(true);
2533
+
2534
+ // Re-inserting the element (what the shell does to move hosts) adds nothing.
2535
+ root.remove();
2536
+ document.body.appendChild(root);
2537
+ expect(root.classList.contains("persona-history-view--enter")).toBe(false);
2538
+ });
2539
+
2540
+ it("reports nothing to await when the platform has no animation support", async () => {
2541
+ const { root, handle } = mount();
2542
+ await flush();
2543
+ expect(handle.playExit()).toBeNull();
2544
+ expect(root.classList.contains("persona-history-view--enter")).toBe(false);
2545
+ });
2546
+
2547
+ it("mirrors the entrance on exit and resolves when the animation finishes", async () => {
2548
+ const waapi = installWaapi();
2549
+ try {
2550
+ const { root, handle } = mount();
2551
+ await flush();
2552
+
2553
+ let resolved = false;
2554
+ const exit = handle.playExit()!;
2555
+ void exit.then(() => {
2556
+ resolved = true;
2557
+ });
2558
+ expect(exit).not.toBeNull();
2559
+ expect(root.classList.contains("persona-history-view--enter")).toBe(false);
2560
+ expect(root.style.pointerEvents).toBe("none");
2561
+
2562
+ // Body only: the bar is furniture and switches back instantly.
2563
+ expect(rootOf(waapi.animations)).toBeUndefined();
2564
+ const bodyAnimation = bodyOf(waapi.animations);
2565
+ expect(bodyAnimation.keyframes.map((frame) => frame.opacity)).toEqual(["1", 0]);
2566
+ expect(bodyAnimation.keyframes.map((frame) => frame.transform)).toEqual([
2567
+ "none",
2568
+ "translateX(20px)",
2569
+ ]);
2570
+ expect(bodyAnimation.options.duration).toBe(160);
2571
+
2572
+ await flush();
2573
+ expect(resolved).toBe(false);
2574
+ waapi.animations.forEach((animation) => animation.settle());
2575
+ await exit;
2576
+ expect(resolved).toBe(true);
2577
+ } finally {
2578
+ waapi.restore();
2579
+ }
2580
+ });
2581
+
2582
+ it("slides a shorter distance from the rail's own trailing edge", async () => {
2583
+ const waapi = installWaapi();
2584
+ try {
2585
+ const { handle } = mount({ presentation: "rail" });
2586
+ await flush();
2587
+ handle.playExit();
2588
+ expect(bodyOf(waapi.animations).keyframes[1]!.transform).toBe("translateX(12px)");
2589
+ } finally {
2590
+ waapi.restore();
2591
+ }
2592
+ });
2593
+
2594
+ it("joins the running exit instead of restarting it", async () => {
2595
+ const waapi = installWaapi();
2596
+ try {
2597
+ const { handle } = mount();
2598
+ await flush();
2599
+ const first = handle.playExit();
2600
+ const second = handle.playExit();
2601
+ expect(second).toBe(first);
2602
+ expect(waapi.animations).toHaveLength(1);
2603
+ } finally {
2604
+ waapi.restore();
2605
+ }
2606
+ });
2607
+
2608
+ it("resolves a cancelled exit rather than leaving the caller waiting", async () => {
2609
+ const waapi = installWaapi();
2610
+ try {
2611
+ const { handle } = mount();
2612
+ await flush();
2613
+ const exit = handle.playExit()!;
2614
+ waapi.animations.forEach((animation) => animation.cancel());
2615
+ await expect(exit).resolves.toBeUndefined();
2616
+ } finally {
2617
+ waapi.restore();
2618
+ }
2619
+ });
2620
+
2621
+ it("skips both entrance and exit under prefers-reduced-motion", async () => {
2622
+ const waapi = installWaapi();
2623
+ const previousMatchMedia = window.matchMedia;
2624
+ window.matchMedia = ((query: string) =>
2625
+ ({
2626
+ matches: query.includes("prefers-reduced-motion"),
2627
+ media: query,
2628
+ addEventListener: () => {},
2629
+ removeEventListener: () => {},
2630
+ }) as unknown as MediaQueryList) as typeof window.matchMedia;
2631
+ try {
2632
+ const { root, handle } = mount();
2633
+ await flush();
2634
+ expect(handle.playExit()).toBeNull();
2635
+ expect(waapi.animations).toHaveLength(0);
2636
+ expect(root.classList.contains("persona-history-view--enter")).toBe(false);
2637
+ // The skeleton's own reduced-motion handling stays in CSS, untouched.
2638
+ expect(injectedCss()).toContain(
2639
+ ".persona-history-view .persona-history-skeleton-bar { animation: none; }"
2640
+ );
2641
+ } finally {
2642
+ window.matchMedia = previousMatchMedia;
2643
+ waapi.restore();
2644
+ }
2645
+ });
2646
+
2647
+ it("cancels a running exit on destroy", async () => {
2648
+ const waapi = installWaapi();
2649
+ try {
2650
+ const record = mount();
2651
+ await flush();
2652
+ record.handle.playExit();
2653
+ record.handle.destroy();
2654
+ mounted.splice(mounted.indexOf(record), 1);
2655
+ expect(waapi.animations.every((animation) => animation.cancelled)).toBe(true);
2656
+ } finally {
2657
+ waapi.restore();
2658
+ }
2659
+ });
2660
+ });