@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
@@ -1,9 +1,52 @@
1
1
  import { PORTALED_OVERLAY_Z_INDEX } from "./constants";
2
+ import {
3
+ DEFAULT_TOOLTIP_DELAY_MS,
4
+ DEFAULT_TOOLTIP_SKIP_DELAY_MS,
5
+ } from "./tooltip-timing";
6
+
7
+ export { DEFAULT_TOOLTIP_DELAY_MS, DEFAULT_TOOLTIP_SKIP_DELAY_MS };
2
8
 
3
9
  const DEFAULT_GAP = 8;
4
10
  const DEFAULT_VIEWPORT_PADDING = 8;
5
11
  const MIN_ARROW_INSET = 10;
6
12
 
13
+ let configuredDelayMs = DEFAULT_TOOLTIP_DELAY_MS;
14
+ let configuredSkipDelayMs = DEFAULT_TOOLTIP_SKIP_DELAY_MS;
15
+ let skipDelayUntil = 0;
16
+
17
+ const clampMs = (value: number): number => Math.max(0, value);
18
+
19
+ /**
20
+ * Set the widget-wide hover timing. Per-attach `delayMs` / `skipDelayMs`
21
+ * still win. Keyboard focus is always immediate.
22
+ */
23
+ export function configureTooltipTiming(timing: {
24
+ delayMs?: number;
25
+ skipDelayMs?: number;
26
+ }): void {
27
+ if (timing.delayMs !== undefined) configuredDelayMs = clampMs(timing.delayMs);
28
+ if (timing.skipDelayMs !== undefined)
29
+ configuredSkipDelayMs = clampMs(timing.skipDelayMs);
30
+ }
31
+
32
+ /** Restore product defaults and clear the skip-delay window. */
33
+ export function resetTooltipTiming(timing?: {
34
+ delayMs?: number;
35
+ skipDelayMs?: number;
36
+ }): void {
37
+ configuredDelayMs = clampMs(timing?.delayMs ?? DEFAULT_TOOLTIP_DELAY_MS);
38
+ configuredSkipDelayMs = clampMs(
39
+ timing?.skipDelayMs ?? DEFAULT_TOOLTIP_SKIP_DELAY_MS
40
+ );
41
+ skipDelayUntil = 0;
42
+ }
43
+
44
+ const isSkipDelayWarm = (): boolean => Date.now() < skipDelayUntil;
45
+
46
+ const markSkipDelay = (skipMs: number): void => {
47
+ skipDelayUntil = skipMs > 0 ? Date.now() + skipMs : 0;
48
+ };
49
+
7
50
  export interface TooltipOptions {
8
51
  /** The control the tooltip describes and positions against. */
9
52
  anchor: HTMLElement;
@@ -11,12 +54,27 @@ export interface TooltipOptions {
11
54
  trigger?: HTMLElement;
12
55
  /** Tooltip copy. A callback keeps live aria-label updates in sync. */
13
56
  text: string | (() => string);
57
+ /**
58
+ * Muted chip after the label, e.g. a keyboard shortcut. Empty renders
59
+ * nothing. A callback is re-read on every reposition, like `text`.
60
+ */
61
+ hint?: string | (() => string);
14
62
  /** Whether the visual tooltip may open. The anchor's accessible name is unaffected. */
15
63
  enabled?: boolean;
16
64
  /** Gap between the control and tooltip, in pixels. */
17
65
  gap?: number;
18
66
  /** Minimum distance from viewport edges, in pixels. */
19
67
  viewportPadding?: number;
68
+ /**
69
+ * Hover wait before the first tooltip. Falls back to the widget
70
+ * `tooltip.delayMs` (default 200). Keyboard focus ignores this.
71
+ */
72
+ delayMs?: number;
73
+ /**
74
+ * After a tooltip closes, later hovers in this window skip `delayMs`.
75
+ * Falls back to the widget `tooltip.skipDelayMs` (default 300).
76
+ */
77
+ skipDelayMs?: number;
20
78
  }
21
79
 
22
80
  export interface TooltipHandle {
@@ -41,6 +99,26 @@ const tooltipContainer = (anchor: HTMLElement): HTMLElement | ShadowRoot | null
41
99
  const clamp = (value: number, min: number, max: number): number =>
42
100
  Math.min(Math.max(value, min), Math.max(min, max));
43
101
 
102
+ // Theme variables live on the widget mount, which a body-portaled tooltip is
103
+ // not inside, so the anchor's resolved values are copied onto it at show time.
104
+ // A theme change lands on the next open.
105
+ const INHERITED_VARS = [
106
+ ...[
107
+ "background",
108
+ "foreground",
109
+ "hint-fg",
110
+ "radius",
111
+ "font-size",
112
+ "padding",
113
+ "max-width",
114
+ "shadow",
115
+ "arrow-display",
116
+ ].map((name) => `--persona-tooltip-${name}`),
117
+ // Inputs to the stylesheet's own var() fallback chains.
118
+ "--persona-font-family",
119
+ "--persona-radius-sm",
120
+ ];
121
+
44
122
  /**
45
123
  * Attach a portaled, viewport-aware tooltip to an icon control.
46
124
  *
@@ -55,6 +133,7 @@ export function attachTooltip(options: TooltipOptions): TooltipHandle {
55
133
  anchor,
56
134
  trigger = anchor,
57
135
  text,
136
+ hint,
58
137
  enabled = true,
59
138
  gap = DEFAULT_GAP,
60
139
  viewportPadding = DEFAULT_VIEWPORT_PADDING,
@@ -65,20 +144,38 @@ export function attachTooltip(options: TooltipOptions): TooltipHandle {
65
144
 
66
145
  let tooltip: HTMLElement | null = null;
67
146
  let label: HTMLElement | null = null;
147
+ let hintChip: HTMLElement | null = null;
68
148
  let detachOpenListeners: (() => void) | null = null;
149
+ let openTimer: ReturnType<Window["setTimeout"]> | undefined;
69
150
  let hovered = false;
70
151
  let focused = false;
71
152
  let destroyed = false;
72
153
 
73
154
  const resolvedText = (): string =>
74
155
  (typeof text === "function" ? text() : text).trim();
156
+ const resolvedHint = (): string =>
157
+ (typeof hint === "function" ? hint() : (hint ?? "")).trim();
158
+ const resolvedDelayMs = (): number =>
159
+ clampMs(options.delayMs ?? configuredDelayMs);
160
+ const resolvedSkipDelayMs = (): number =>
161
+ clampMs(options.skipDelayMs ?? configuredSkipDelayMs);
162
+
163
+ const clearOpenTimer = (): void => {
164
+ if (openTimer === undefined) return;
165
+ currentWindow().clearTimeout(openTimer);
166
+ openTimer = undefined;
167
+ };
75
168
 
76
169
  const hide = (): void => {
170
+ clearOpenTimer();
171
+ const wasOpen = tooltip !== null;
77
172
  detachOpenListeners?.();
78
173
  detachOpenListeners = null;
79
174
  tooltip?.remove();
80
175
  tooltip = null;
81
176
  label = null;
177
+ hintChip = null;
178
+ if (wasOpen) markSkipDelay(resolvedSkipDelayMs());
82
179
  };
83
180
 
84
181
  const reposition = (): void => {
@@ -138,11 +235,30 @@ export function attachTooltip(options: TooltipOptions): TooltipHandle {
138
235
  tooltip.dataset.state = "measuring";
139
236
  tooltip.style.zIndex = String(PORTALED_OVERLAY_Z_INDEX);
140
237
 
238
+ const computed = ownerWindow.getComputedStyle?.(anchor);
239
+ if (computed) {
240
+ for (const name of INHERITED_VARS) {
241
+ const value = computed.getPropertyValue(name).trim();
242
+ if (value) tooltip.style.setProperty(name, value);
243
+ }
244
+ // Body portal inherits the host page font, not the widget's; the anchor's
245
+ // resolved family is the font the control actually renders in.
246
+ if (computed.fontFamily) tooltip.style.fontFamily = computed.fontFamily;
247
+ }
248
+
141
249
  label = ownerDocument.createElement("span");
142
250
  label.className = "persona-control-tooltip__label";
143
251
  label.textContent = copy;
144
252
  tooltip.appendChild(label);
145
253
 
254
+ const hintCopy = resolvedHint();
255
+ if (hintCopy) {
256
+ hintChip = ownerDocument.createElement("span");
257
+ hintChip.className = "persona-control-tooltip__hint";
258
+ hintChip.textContent = hintCopy;
259
+ tooltip.appendChild(hintChip);
260
+ }
261
+
146
262
  const arrow = ownerDocument.createElement("span");
147
263
  arrow.className = "persona-control-tooltip__arrow";
148
264
  tooltip.appendChild(arrow);
@@ -157,6 +273,7 @@ export function attachTooltip(options: TooltipOptions): TooltipHandle {
157
273
  return;
158
274
  }
159
275
  if (label) label.textContent = resolvedText();
276
+ if (hintChip) hintChip.textContent = resolvedHint();
160
277
  reposition();
161
278
  };
162
279
  ownerWindow.addEventListener("scroll", onReposition, true);
@@ -182,8 +299,21 @@ export function attachTooltip(options: TooltipOptions): TooltipHandle {
182
299
  };
183
300
  };
184
301
 
302
+ const scheduleShow = (immediate: boolean): void => {
303
+ if (destroyed || tooltip) return;
304
+ clearOpenTimer();
305
+ if (immediate || resolvedDelayMs() <= 0 || isSkipDelayWarm()) {
306
+ show();
307
+ return;
308
+ }
309
+ openTimer = currentWindow().setTimeout(() => {
310
+ openTimer = undefined;
311
+ show();
312
+ }, resolvedDelayMs());
313
+ };
314
+
185
315
  const syncVisibility = (): void => {
186
- if (hovered || focused) show();
316
+ if (hovered || focused) scheduleShow(focused);
187
317
  else hide();
188
318
  };
189
319
  const onMouseEnter = (): void => {
@@ -200,25 +330,36 @@ export function attachTooltip(options: TooltipOptions): TooltipHandle {
200
330
  syncVisibility();
201
331
  };
202
332
  const onFocus = (): void => {
203
- focused = true;
333
+ // Only keyboard-visible focus opens the tooltip: a shell moving focus
334
+ // programmatically after a mouse interaction must not pop one.
335
+ try {
336
+ focused = anchor.matches(":focus-visible");
337
+ } catch {
338
+ focused = true;
339
+ }
204
340
  syncVisibility();
205
341
  };
206
342
  const onBlur = (): void => {
207
343
  focused = false;
208
344
  syncVisibility();
209
345
  };
210
- const onKeyDown = (event: KeyboardEvent): void => {
211
- if (event.key !== "Escape") return;
346
+ // Activation dismisses the tooltip. The flags re-arm on the next mouseenter
347
+ // or keyboard-visible focus, which re-reads whatever label the control now has.
348
+ const dismiss = (): void => {
212
349
  hovered = false;
213
350
  focused = false;
214
351
  hide();
215
352
  };
353
+ const onKeyDown = (event: KeyboardEvent): void => {
354
+ if (event.key === "Escape") dismiss();
355
+ };
216
356
 
217
357
  trigger.addEventListener("mouseenter", onMouseEnter);
218
358
  trigger.addEventListener("mouseleave", onMouseLeave);
219
359
  anchor.addEventListener("focus", onFocus);
220
360
  anchor.addEventListener("blur", onBlur);
221
361
  anchor.addEventListener("keydown", onKeyDown);
362
+ anchor.addEventListener("click", dismiss);
222
363
 
223
364
  const handle: TooltipHandle = {
224
365
  get isOpen() {
@@ -236,6 +377,7 @@ export function attachTooltip(options: TooltipOptions): TooltipHandle {
236
377
  anchor.removeEventListener("focus", onFocus);
237
378
  anchor.removeEventListener("blur", onBlur);
238
379
  anchor.removeEventListener("keydown", onKeyDown);
380
+ anchor.removeEventListener("click", dismiss);
239
381
  handles.delete(anchor);
240
382
  },
241
383
  };
@@ -0,0 +1,425 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import {
6
+ createVisitorStore,
7
+ visitorStoreKeys,
8
+ type VisitorStore,
9
+ type VisitorStoreChange,
10
+ } from "./visitor-store";
11
+
12
+ const CLIENT_TOKEN = "ct_live_abc123";
13
+ const PREFIX = "persona-";
14
+
15
+ interface LeaseShape {
16
+ ownerNonce?: unknown;
17
+ expiresAt?: unknown;
18
+ }
19
+
20
+ interface LockManagerLike {
21
+ request<T>(name: string, callback: () => Promise<T> | T): Promise<T>;
22
+ }
23
+
24
+ type NavigatorWithLocks = Navigator & { locks?: LockManagerLike };
25
+
26
+ const sha256Hex = async (input: string): Promise<string> => {
27
+ const digest = await globalThis.crypto.subtle.digest(
28
+ "SHA-256",
29
+ new TextEncoder().encode(input)
30
+ );
31
+ return Array.from(new Uint8Array(digest))
32
+ .map((b) => b.toString(16).padStart(2, "0"))
33
+ .join("");
34
+ };
35
+
36
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
37
+ /** Let the store's async `ready` chain and any queued microtasks settle. */
38
+ const flush = () => sleep(0);
39
+
40
+ const stores: VisitorStore[] = [];
41
+ const makeStore = (
42
+ clientToken = CLIENT_TOKEN,
43
+ keyPrefix = PREFIX,
44
+ persistDisabled = false
45
+ ): VisitorStore => {
46
+ const store = createVisitorStore(clientToken, keyPrefix, persistDisabled);
47
+ stores.push(store);
48
+ return store;
49
+ };
50
+
51
+ /** Serializes callbacks per lock name, like a real single-mode Web Lock. */
52
+ const installFakeLocks = (): { requests: string[] } => {
53
+ const chains = new Map<string, Promise<unknown>>();
54
+ const requests: string[] = [];
55
+ const manager: LockManagerLike = {
56
+ request<T>(name: string, callback: () => Promise<T> | T): Promise<T> {
57
+ requests.push(name);
58
+ const previous = chains.get(name) ?? Promise.resolve();
59
+ const run = previous.then(() => callback());
60
+ chains.set(
61
+ name,
62
+ run.then(
63
+ () => undefined,
64
+ () => undefined
65
+ )
66
+ );
67
+ return run;
68
+ },
69
+ };
70
+ Object.defineProperty(globalThis.navigator, "locks", {
71
+ value: manager,
72
+ configurable: true,
73
+ writable: true,
74
+ });
75
+ return { requests };
76
+ };
77
+
78
+ describe("visitor store", () => {
79
+ afterEach(() => {
80
+ for (const store of stores.splice(0)) store.destroy();
81
+ // lib.dom types `Navigator.locks` as required, so drop it reflectively.
82
+ Reflect.deleteProperty(globalThis.navigator, "locks");
83
+ window.localStorage.clear();
84
+ vi.restoreAllMocks();
85
+ });
86
+
87
+ it("namespaces the storage key by key prefix and a sha-256 of the client token", async () => {
88
+ const hash = await sha256Hex(CLIENT_TOKEN);
89
+ const keys = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
90
+
91
+ expect(keys.storageKey).toBe(`persona-visitor:${hash}`);
92
+ expect(keys.leaseKey).toBe(`persona-visitor-init-lock:${hash}`);
93
+ expect(keys.storageKey).not.toContain(CLIENT_TOKEN);
94
+
95
+ const scoped = await visitorStoreKeys(CLIENT_TOKEN, "myapp-");
96
+ expect(scoped.storageKey).toBe(`myapp-visitor:${hash}`);
97
+ // The lock name also folds in the prefix, so two surfaces do not share it.
98
+ expect(scoped.lockName).not.toBe(keys.lockName);
99
+ });
100
+
101
+ it("round-trips the raw token string under the namespaced key", async () => {
102
+ const { storageKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
103
+ const store = makeStore();
104
+
105
+ await store.set("cvt_alpha");
106
+
107
+ expect(window.localStorage.getItem(storageKey)).toBe("cvt_alpha");
108
+ expect(await store.get()).toBe("cvt_alpha");
109
+
110
+ await store.clear();
111
+ expect(window.localStorage.getItem(storageKey)).toBeNull();
112
+ expect(await store.get()).toBeNull();
113
+ });
114
+
115
+ it("hydrates an existing credential silently at boot", async () => {
116
+ const { storageKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
117
+ window.localStorage.setItem(storageKey, "cvt_seeded");
118
+
119
+ const store = makeStore();
120
+ const changes: VisitorStoreChange[] = [];
121
+ store.subscribe((change) => changes.push(change));
122
+
123
+ expect(await store.get()).toBe("cvt_seeded");
124
+ expect(store.revision()).toBe(0);
125
+ expect(changes).toEqual([]);
126
+ });
127
+
128
+ it("bumps the revision and notifies once on a local set", async () => {
129
+ const store = makeStore();
130
+ const changes: VisitorStoreChange[] = [];
131
+ store.subscribe((change) => changes.push(change));
132
+
133
+ await store.set("cvt_alpha");
134
+
135
+ expect(store.revision()).toBe(1);
136
+ expect(changes).toEqual([
137
+ {
138
+ previousToken: null,
139
+ token: "cvt_alpha",
140
+ source: "local",
141
+ revision: 1,
142
+ },
143
+ ]);
144
+ });
145
+
146
+ it("treats a set of the same value as a no-op", async () => {
147
+ const store = makeStore();
148
+ await store.set("cvt_alpha");
149
+
150
+ const changes: VisitorStoreChange[] = [];
151
+ store.subscribe((change) => changes.push(change));
152
+ await store.set("cvt_alpha");
153
+
154
+ expect(store.revision()).toBe(1);
155
+ expect(changes).toEqual([]);
156
+ });
157
+
158
+ it("adopts an external token replacement from a storage event", async () => {
159
+ const { storageKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
160
+ const store = makeStore();
161
+ await store.set("cvt_alpha");
162
+
163
+ const changes: VisitorStoreChange[] = [];
164
+ store.subscribe((change) => changes.push(change));
165
+
166
+ // Another tab replaced the credential: value first, then the notification.
167
+ window.localStorage.setItem(storageKey, "cvt_beta");
168
+ window.dispatchEvent(
169
+ new StorageEvent("storage", {
170
+ key: storageKey,
171
+ oldValue: "cvt_alpha",
172
+ newValue: "cvt_beta",
173
+ })
174
+ );
175
+
176
+ expect(changes).toEqual([
177
+ {
178
+ previousToken: "cvt_alpha",
179
+ token: "cvt_beta",
180
+ source: "external",
181
+ revision: 2,
182
+ },
183
+ ]);
184
+ expect(store.revision()).toBe(2);
185
+ expect(await store.get()).toBe("cvt_beta");
186
+ });
187
+
188
+ it("observes null when another tab clears the whole store (key: null)", async () => {
189
+ const store = makeStore();
190
+ await store.set("cvt_alpha");
191
+
192
+ const changes: VisitorStoreChange[] = [];
193
+ store.subscribe((change) => changes.push(change));
194
+
195
+ window.localStorage.clear();
196
+ window.dispatchEvent(
197
+ new StorageEvent("storage", { key: null, oldValue: null, newValue: null })
198
+ );
199
+
200
+ expect(changes).toEqual([
201
+ {
202
+ previousToken: "cvt_alpha",
203
+ token: null,
204
+ source: "external",
205
+ revision: 2,
206
+ },
207
+ ]);
208
+ expect(await store.get()).toBeNull();
209
+ });
210
+
211
+ it("ignores storage events for unrelated keys", async () => {
212
+ const store = makeStore();
213
+ await store.set("cvt_alpha");
214
+
215
+ const changes: VisitorStoreChange[] = [];
216
+ store.subscribe((change) => changes.push(change));
217
+
218
+ window.dispatchEvent(
219
+ new StorageEvent("storage", {
220
+ key: "persona-plugin:home-screen:view",
221
+ oldValue: null,
222
+ newValue: "home",
223
+ })
224
+ );
225
+
226
+ expect(changes).toEqual([]);
227
+ expect(store.revision()).toBe(1);
228
+ });
229
+
230
+ it("keeps the token in memory only when persistence is disabled", async () => {
231
+ const { storageKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
232
+ const store = makeStore(CLIENT_TOKEN, PREFIX, true);
233
+
234
+ const changes: VisitorStoreChange[] = [];
235
+ store.subscribe((change) => changes.push(change));
236
+
237
+ await store.set("cvt_alpha");
238
+
239
+ expect(window.localStorage.getItem(storageKey)).toBeNull();
240
+ expect(await store.get()).toBe("cvt_alpha");
241
+ expect(store.revision()).toBe(1);
242
+ expect(changes).toHaveLength(1);
243
+ expect(changes[0]?.source).toBe("local");
244
+ });
245
+
246
+ it("degrades to memory when localStorage throws (private mode)", async () => {
247
+ const setItem = vi
248
+ .spyOn(Storage.prototype, "setItem")
249
+ .mockImplementation(() => {
250
+ throw new Error("blocked");
251
+ });
252
+ const getItem = vi
253
+ .spyOn(Storage.prototype, "getItem")
254
+ .mockImplementation(() => {
255
+ throw new Error("blocked");
256
+ });
257
+
258
+ const store = makeStore();
259
+ await expect(store.set("cvt_alpha")).resolves.toBeUndefined();
260
+ expect(await store.get()).toBe("cvt_alpha");
261
+
262
+ setItem.mockRestore();
263
+ getItem.mockRestore();
264
+ });
265
+
266
+ it("detects same-document drift on get() and reports it as external", async () => {
267
+ const { storageKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
268
+ const store = makeStore();
269
+ await store.set("cvt_alpha");
270
+
271
+ const changes: VisitorStoreChange[] = [];
272
+ store.subscribe((change) => changes.push(change));
273
+
274
+ // A sibling writer in this same document fires no storage event.
275
+ window.localStorage.setItem(storageKey, "cvt_gamma");
276
+
277
+ expect(await store.get()).toBe("cvt_gamma");
278
+ expect(store.revision()).toBe(2);
279
+ expect(changes).toEqual([
280
+ {
281
+ previousToken: "cvt_alpha",
282
+ token: "cvt_gamma",
283
+ source: "external",
284
+ revision: 2,
285
+ },
286
+ ]);
287
+ });
288
+
289
+ it("stops notifying after unsubscribe and after destroy", async () => {
290
+ const { storageKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
291
+ const store = makeStore();
292
+ const first = vi.fn();
293
+ const unsubscribe = store.subscribe(first);
294
+
295
+ await store.set("cvt_alpha");
296
+ expect(first).toHaveBeenCalledTimes(1);
297
+
298
+ unsubscribe();
299
+ await store.set("cvt_beta");
300
+ expect(first).toHaveBeenCalledTimes(1);
301
+
302
+ store.destroy();
303
+ const afterDestroy = vi.fn();
304
+ store.subscribe(afterDestroy);
305
+ const revisionAtDestroy = store.revision();
306
+
307
+ window.localStorage.setItem(storageKey, "cvt_delta");
308
+ window.dispatchEvent(
309
+ new StorageEvent("storage", {
310
+ key: storageKey,
311
+ oldValue: "cvt_beta",
312
+ newValue: "cvt_delta",
313
+ })
314
+ );
315
+
316
+ expect(afterDestroy).not.toHaveBeenCalled();
317
+ expect(store.revision()).toBe(revisionAtDestroy);
318
+ });
319
+
320
+ it("matches only on the full identity tuple", () => {
321
+ const store = makeStore();
322
+
323
+ expect(store.matches(CLIENT_TOKEN, PREFIX, false)).toBe(true);
324
+ expect(store.matches("ct_live_other", PREFIX, false)).toBe(false);
325
+ expect(store.matches(CLIENT_TOKEN, "myapp-", false)).toBe(false);
326
+ expect(store.matches(CLIENT_TOKEN, PREFIX, true)).toBe(false);
327
+ });
328
+
329
+ it("serializes first init through Web Locks when available", async () => {
330
+ const { requests } = installFakeLocks();
331
+ const { lockName } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
332
+ const store = makeStore();
333
+ await store.ready;
334
+
335
+ const order: string[] = [];
336
+ let releaseFirst: () => void = () => {};
337
+ const firstGate = new Promise<void>((resolve) => {
338
+ releaseFirst = resolve;
339
+ });
340
+
341
+ const first = store.withFirstInitLock(async () => {
342
+ order.push("first:start");
343
+ await firstGate;
344
+ order.push("first:end");
345
+ return "a";
346
+ });
347
+ const second = store.withFirstInitLock(async () => {
348
+ order.push("second:start");
349
+ return "b";
350
+ });
351
+
352
+ await flush();
353
+ expect(order).toEqual(["first:start"]);
354
+
355
+ releaseFirst();
356
+ expect(await first).toBe("a");
357
+ expect(await second).toBe("b");
358
+ expect(order).toEqual(["first:start", "first:end", "second:start"]);
359
+ expect(requests).toEqual([lockName, lockName]);
360
+ });
361
+
362
+ it("falls back to a secret-free localStorage lease without Web Locks", async () => {
363
+ expect((globalThis.navigator as NavigatorWithLocks).locks).toBeUndefined();
364
+
365
+ const { leaseKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
366
+ const store = makeStore();
367
+ await store.set("cvt_secret_value");
368
+
369
+ const seen: { lease: string | null } = { lease: null };
370
+ const result = await store.withFirstInitLock(async () => {
371
+ seen.lease = window.localStorage.getItem(leaseKey);
372
+ return "done";
373
+ });
374
+
375
+ expect(result).toBe("done");
376
+ expect(typeof seen.lease).toBe("string");
377
+
378
+ const raw = String(seen.lease);
379
+ const parsed = JSON.parse(raw) as LeaseShape;
380
+ expect(typeof parsed.ownerNonce).toBe("string");
381
+ expect(typeof parsed.expiresAt).toBe("number");
382
+ expect(Number(parsed.expiresAt)).toBeGreaterThan(Date.now());
383
+ expect(raw).not.toContain(CLIENT_TOKEN);
384
+ expect(raw).not.toContain("cvt_");
385
+
386
+ expect(window.localStorage.getItem(leaseKey)).toBeNull();
387
+ });
388
+
389
+ it("recovers an expired foreign lease", async () => {
390
+ const { leaseKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
391
+ window.localStorage.setItem(
392
+ leaseKey,
393
+ JSON.stringify({ ownerNonce: "foreign", expiresAt: Date.now() - 60_000 })
394
+ );
395
+
396
+ const store = makeStore();
397
+ const fn = vi.fn(async () => "minted");
398
+
399
+ expect(await store.withFirstInitLock(fn)).toBe("minted");
400
+ expect(fn).toHaveBeenCalledTimes(1);
401
+ expect(window.localStorage.getItem(leaseKey)).toBeNull();
402
+ });
403
+
404
+ it("lets two contending instances both complete and leaves no lease row", async () => {
405
+ const { leaseKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
406
+ const storeA = makeStore();
407
+ const storeB = makeStore();
408
+ await Promise.all([storeA.ready, storeB.ready]);
409
+
410
+ const [a, b] = await Promise.all([
411
+ storeA.withFirstInitLock(async () => {
412
+ await sleep(5);
413
+ return "a";
414
+ }),
415
+ storeB.withFirstInitLock(async () => {
416
+ await sleep(5);
417
+ return "b";
418
+ }),
419
+ ]);
420
+
421
+ expect(a).toBe("a");
422
+ expect(b).toBe("b");
423
+ expect(window.localStorage.getItem(leaseKey)).toBeNull();
424
+ }, 20_000);
425
+ });