@djangocfg/ui-tools 2.1.409 → 2.1.411

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 (54) hide show
  1. package/package.json +13 -13
  2. package/src/{tools/Chat/highlight → lib/browser-bridge}/README.md +46 -18
  3. package/src/lib/browser-bridge/commands/chat.ts +42 -0
  4. package/src/lib/browser-bridge/commands/highlight.ts +70 -0
  5. package/src/lib/browser-bridge/commands/index.ts +15 -0
  6. package/src/lib/browser-bridge/commands/inspect.ts +31 -0
  7. package/src/lib/browser-bridge/commands/scroll.ts +31 -0
  8. package/src/lib/browser-bridge/commands/write.ts +45 -0
  9. package/src/lib/browser-bridge/directive-bus.ts +120 -0
  10. package/src/lib/browser-bridge/index.ts +56 -0
  11. package/src/lib/browser-bridge/logger.ts +27 -0
  12. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/HighlightOverlay.tsx +14 -0
  13. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/__tests__/HighlightOverlay.test.tsx +52 -0
  14. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/__tests__/resolveRef.test.ts +39 -0
  15. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/index.ts +8 -5
  16. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/resolveRef.ts +5 -0
  17. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/useHighlightTargets.ts +58 -27
  18. package/src/lib/browser-bridge/overlay/waitForVisible.ts +70 -0
  19. package/src/lib/browser-bridge/registry.ts +41 -0
  20. package/src/lib/browser-bridge/setBridgeResolver.ts +42 -0
  21. package/src/lib/browser-bridge/window.ts +76 -0
  22. package/src/lib/page-snapshot/capture/walk.ts +13 -5
  23. package/src/lib/page-snapshot/engine.ts +9 -4
  24. package/src/lib/page-snapshot/index.ts +5 -0
  25. package/src/lib/page-snapshot/react/provider.tsx +70 -3
  26. package/src/lib/page-snapshot/react/use-page-snapshot.ts +10 -0
  27. package/src/lib/page-snapshot/refs/__tests__/locator.test.ts +94 -0
  28. package/src/lib/page-snapshot/refs/__tests__/registry.test.ts +59 -3
  29. package/src/lib/page-snapshot/refs/locator.ts +218 -0
  30. package/src/lib/page-snapshot/refs/registry.ts +29 -14
  31. package/src/tools/Chat/README.md +1 -1
  32. package/src/tools/Chat/constants.ts +24 -1
  33. package/src/tools/Chat/context/ChatProvider.tsx +17 -2
  34. package/src/tools/Chat/core/logger.ts +15 -2
  35. package/src/tools/Chat/index.ts +34 -2
  36. package/src/tools/Chat/launcher/ChatDock.tsx +13 -3
  37. package/src/tools/Chat/launcher/ChatFAB.tsx +4 -2
  38. package/src/tools/Chat/launcher/ChatGreeting.tsx +3 -2
  39. package/src/tools/Chat/launcher/ChatLauncher.tsx +42 -7
  40. package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +3 -2
  41. package/src/tools/Chat/launcher/header/ChatHeader.tsx +2 -0
  42. package/src/tools/Chat/launcher/header/ChatHeaderActionButton.tsx +2 -0
  43. package/src/tools/Chat/launcher/header/ChatHeaderLanguageButton.tsx +2 -2
  44. package/src/tools/Chat/launcher/header/HeaderSlots.tsx +16 -9
  45. package/src/tools/Chat/lazy.tsx +34 -2
  46. package/src/tools/Chat/public.ts +16 -0
  47. package/src/tools/Chat/settings/README.md +87 -0
  48. package/src/tools/Chat/settings/__tests__/useChatSettings.test.tsx +84 -0
  49. package/src/tools/Chat/settings/__tests__/useLocalStorage.test.tsx +138 -0
  50. package/src/tools/Chat/settings/index.ts +23 -0
  51. package/src/tools/Chat/settings/types.ts +108 -0
  52. package/src/tools/Chat/settings/useChatSettings.ts +168 -0
  53. /package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/SpotlightCanvas.tsx +0 -0
  54. /package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/types.ts +0 -0
@@ -11,6 +11,12 @@ import {
11
11
 
12
12
  import { useLocationProperty } from '@djangocfg/ui-core/hooks';
13
13
 
14
+ import {
15
+ installChatBridge,
16
+ setBridgeResolver,
17
+ } from '../../browser-bridge';
18
+ import { useChatSettings } from '../../../tools/Chat/settings';
19
+
14
20
  import type { CaptureEngineOptions, CaptureResult } from '../engine';
15
21
  import { PageSnapshotEngine } from '../engine';
16
22
  import {
@@ -23,10 +29,17 @@ export interface PageSnapshotProviderProps {
23
29
  /** Engine configuration. */
24
30
  config?: CaptureEngineOptions;
25
31
  /**
26
- * Start opted-in. Default false — the user must explicitly enable
27
- * sharing page context.
32
+ * Opt-in value used when nothing is stored yet. Default false — the
33
+ * user must explicitly enable sharing page context. Once the user
34
+ * toggles it, the choice is persisted and this is ignored.
28
35
  */
29
36
  defaultLinked?: boolean;
37
+ /**
38
+ * localStorage key for the centralized chat-settings object that holds
39
+ * the opt-in (along with the rest of the chat's persisted settings).
40
+ * Override to scope the chat settings per app / per chat.
41
+ */
42
+ storageKey?: string;
30
43
  children: ReactNode;
31
44
  }
32
45
 
@@ -43,12 +56,42 @@ export interface PageSnapshotProviderProps {
43
56
  export function PageSnapshotProvider({
44
57
  config,
45
58
  defaultLinked = false,
59
+ storageKey,
46
60
  children,
47
61
  }: PageSnapshotProviderProps) {
48
- const [isLinked, setIsLinked] = useState(defaultLinked);
62
+ // The opt-in choice persists across sessions — the user enables screen
63
+ // sharing once and it is remembered. It now lives in the centralized
64
+ // chat-settings object (`ChatSettings.pageContext.linked`) instead of a
65
+ // standalone key, so the chat owns all its persisted state in one place.
66
+ const settingsDefaults = useMemo(
67
+ () => ({ pageContext: { linked: defaultLinked } }),
68
+ [defaultLinked],
69
+ );
70
+ const { settings, setPageContextLinked } = useChatSettings({
71
+ storageKey,
72
+ defaults: settingsDefaults,
73
+ });
74
+ const isLinked = settings.pageContext.linked;
75
+ const setIsLinked = setPageContextLinked;
49
76
  const [isStale, setIsStale] = useState(false);
50
77
  const [lastSnapshot, setLastSnapshot] = useState<CaptureResult | null>(null);
51
78
 
79
+ // The snapshot that actually rode the most recent chat message.
80
+ //
81
+ // CST ref ids (`@eN`) are positional — assigned by DOM-order traversal
82
+ // at capture time — so they are NOT stable across captures: `@e22` of
83
+ // one snapshot need not be `@e22` of another. A `point` directive the
84
+ // assistant returns cites refs from the snapshot it was given. To
85
+ // resolve that directive correctly the overlay must use the registry
86
+ // of *that* snapshot, not a newer one.
87
+ //
88
+ // `lastSnapshot` tracks the live page and is overwritten by every
89
+ // route-change auto-capture, so it cannot be trusted for directive
90
+ // resolution. This state holds the sent snapshot and is only ever
91
+ // replaced on the next send — it survives route changes that happen
92
+ // while a reply (and its directives) is still streaming in.
93
+ const [sentSnapshot, setSentSnapshot] = useState<CaptureResult | null>(null);
94
+
52
95
  // Engine is browser-only. Construct it once, lazily, after mount —
53
96
  // a ref (not state) since it never needs to trigger a re-render.
54
97
  const engineRef = useRef<PageSnapshotEngine | null>(null);
@@ -112,6 +155,10 @@ export function PageSnapshotProvider({
112
155
  if (!isLinked) return undefined;
113
156
  const result = capture();
114
157
  if (!result) return undefined;
158
+ // This is the snapshot the assistant will see — pin it so any
159
+ // `point` directive in the reply resolves against the exact ref
160
+ // registry that produced the refs the assistant cited.
161
+ setSentSnapshot(result);
115
162
  return { pageContext: result.payload };
116
163
  }, [isLinked, capture]);
117
164
 
@@ -132,6 +179,24 @@ export function PageSnapshotProvider({
132
179
  // `pathname` in deps: a new route re-captures automatically.
133
180
  }, [isLinked, pathname, runCapture]);
134
181
 
182
+ // Install the dev-only `window.__chatBridge` once — manual testing of
183
+ // highlight / focus commands without an AI round-trip.
184
+ useEffect(() => {
185
+ installChatBridge();
186
+ }, []);
187
+
188
+ // Keep the bridge's ref resolver pointed at the latest snapshot, so
189
+ // console-driven `window.__chatBridge.highlight()` commands resolve
190
+ // refs against the current page. This is intentionally the *latest*
191
+ // snapshot, not `sentSnapshot`: manual testing highlights what is on
192
+ // screen now. AI-directive resolution is a separate path — it uses
193
+ // `sentSnapshot` (see `DirectiveOverlay`) so it stays pinned to the
194
+ // registry the assistant actually saw.
195
+ useEffect(() => {
196
+ setBridgeResolver(lastSnapshot?.refs ?? null);
197
+ return () => setBridgeResolver(null);
198
+ }, [lastSnapshot]);
199
+
135
200
  const value = useMemo<PageSnapshotContextValue>(
136
201
  () => ({
137
202
  isLinked,
@@ -140,6 +205,7 @@ export function PageSnapshotProvider({
140
205
  generatePreview,
141
206
  getChatMetadata,
142
207
  lastSnapshot,
208
+ sentSnapshot,
143
209
  isStale,
144
210
  refresh,
145
211
  }),
@@ -149,6 +215,7 @@ export function PageSnapshotProvider({
149
215
  generatePreview,
150
216
  getChatMetadata,
151
217
  lastSnapshot,
218
+ sentSnapshot,
152
219
  isStale,
153
220
  refresh,
154
221
  ],
@@ -40,6 +40,16 @@ export interface PageSnapshotContextValue {
40
40
  /** The most recent captured snapshot, if any. */
41
41
  lastSnapshot: CaptureResult | null;
42
42
 
43
+ /**
44
+ * The snapshot that rode the most recent chat message — the one the
45
+ * assistant was given. CST ref ids are positional, so a `point`
46
+ * directive in the reply must be resolved against *this* snapshot's
47
+ * ref registry, never `lastSnapshot` (which the route-change
48
+ * auto-capture overwrites) and never a fresh capture (which re-numbers
49
+ * refs). Null until the first message is sent.
50
+ */
51
+ sentSnapshot: CaptureResult | null;
52
+
43
53
  /** Whether the captured context is stale vs the current page. */
44
54
  isStale: boolean;
45
55
 
@@ -0,0 +1,94 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+
4
+ import { computeLocator, resolveLocator } from '../locator';
5
+
6
+ afterEach(() => {
7
+ document.body.innerHTML = '';
8
+ });
9
+
10
+ describe('computeLocator', () => {
11
+ it('builds an id selector when the element has a unique id', () => {
12
+ const el = document.createElement('button');
13
+ el.id = 'submit-btn';
14
+ el.textContent = 'Submit';
15
+ document.body.appendChild(el);
16
+
17
+ const locator = computeLocator(el, 'button');
18
+ expect(locator.selector).toBe('#submit-btn');
19
+ expect(locator.role).toBe('button');
20
+ expect(locator.name).toBe('Submit');
21
+ expect(locator.tag).toBe('button');
22
+ });
23
+
24
+ it('prefers data-testid over a structural path', () => {
25
+ const el = document.createElement('a');
26
+ el.setAttribute('data-testid', 'nav-home');
27
+ el.textContent = 'Home';
28
+ document.body.appendChild(el);
29
+
30
+ expect(computeLocator(el, 'link').selector).toBe('a[data-testid="nav-home"]');
31
+ });
32
+
33
+ it('falls back to a structural path when no stable attribute exists', () => {
34
+ document.body.innerHTML = '<section><div></div><a>One</a><a>Two</a></section>';
35
+ const second = document.body.querySelectorAll('a')[1] as HTMLElement;
36
+
37
+ const locator = computeLocator(second, 'link');
38
+ expect(locator.selector).toContain('nth-of-type');
39
+ // The structural path must re-find the same element.
40
+ expect(document.querySelector(locator.selector)).toBe(second);
41
+ });
42
+ });
43
+
44
+ describe('resolveLocator', () => {
45
+ it('resolves via selector when structure is intact', () => {
46
+ const el = document.createElement('button');
47
+ el.id = 'go';
48
+ el.textContent = 'Go';
49
+ document.body.appendChild(el);
50
+
51
+ const locator = computeLocator(el, 'button');
52
+ expect(resolveLocator(locator)).toBe(el);
53
+ });
54
+
55
+ it('resolves by role + name when the selector breaks', () => {
56
+ // Capture with a structural path (no stable attribute).
57
+ document.body.innerHTML = '<div><a>Browse Catalog</a></div>';
58
+ const original = document.body.querySelector('a') as HTMLElement;
59
+ const locator = computeLocator(original, 'link');
60
+
61
+ // Re-render shifts the element: extra wrapper + sibling inserted, so
62
+ // the captured nth-of-type path no longer matches it.
63
+ document.body.innerHTML =
64
+ '<header><nav><span>x</span><a>Browse Catalog</a></nav></header>';
65
+
66
+ const resolved = resolveLocator(locator);
67
+ expect(resolved).not.toBeNull();
68
+ expect(resolved?.textContent).toBe('Browse Catalog');
69
+ });
70
+
71
+ it('returns null when the element is genuinely gone', () => {
72
+ const el = document.createElement('a');
73
+ el.textContent = 'Vanishing Link';
74
+ document.body.appendChild(el);
75
+ const locator = computeLocator(el, 'link');
76
+
77
+ document.body.innerHTML = '';
78
+ expect(resolveLocator(locator)).toBeNull();
79
+ });
80
+
81
+ it('matches an ARIA-roled element by role + name', () => {
82
+ document.body.innerHTML =
83
+ '<div role="button" aria-label="Open menu">≡</div>';
84
+ const original = document.body.firstElementChild as HTMLElement;
85
+ const locator = computeLocator(original, 'button');
86
+
87
+ // Recreate the node — same role + accessible name, new identity.
88
+ document.body.innerHTML =
89
+ '<span></span><div role="button" aria-label="Open menu">≡</div>';
90
+ const fresh = document.body.querySelectorAll('[role="button"]')[0];
91
+
92
+ expect(resolveLocator(locator)).toBe(fresh);
93
+ });
94
+ });
@@ -1,8 +1,13 @@
1
1
  // @vitest-environment jsdom
2
- import { describe, expect, it } from 'vitest';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
3
 
4
+ import { computeLocator } from '../locator';
4
5
  import { RefRegistry } from '../registry';
5
6
 
7
+ afterEach(() => {
8
+ document.body.innerHTML = '';
9
+ });
10
+
6
11
  describe('RefRegistry', () => {
7
12
  it('reports unknown refs as unresolved', () => {
8
13
  const reg = new RefRegistry('snap-1');
@@ -13,12 +18,63 @@ describe('RefRegistry', () => {
13
18
  });
14
19
 
15
20
  it('resolves a registered ref to its element', () => {
16
- const reg = new RefRegistry('snap-1');
17
21
  const el = document.createElement('button');
18
- reg.set('@e1', el);
22
+ el.textContent = 'Save';
23
+ document.body.appendChild(el);
24
+
25
+ const reg = new RefRegistry('snap-1');
26
+ reg.set('@e1', computeLocator(el, 'button'));
19
27
 
20
28
  expect(reg.has('@e1')).toBe(true);
21
29
  expect(reg.resolve('@e1')).toBe(el);
22
30
  expect(reg.size).toBe(1);
23
31
  });
32
+
33
+ it('resolves after the original node is replaced by a re-render', () => {
34
+ // Capture against the original node.
35
+ const original = document.createElement('a');
36
+ original.id = 'browse';
37
+ original.textContent = 'Browse Catalog';
38
+ document.body.appendChild(original);
39
+
40
+ const reg = new RefRegistry('snap-1');
41
+ reg.set('@e1', computeLocator(original, 'link'));
42
+
43
+ // React re-render: the node is recreated as a fresh, equivalent one.
44
+ original.remove();
45
+ const fresh = document.createElement('a');
46
+ fresh.id = 'browse';
47
+ fresh.textContent = 'Browse Catalog';
48
+ document.body.appendChild(fresh);
49
+
50
+ const resolved = reg.resolve('@e1');
51
+ expect(resolved).toBe(fresh);
52
+ expect(resolved).not.toBe(original);
53
+ });
54
+
55
+ it('returns null when the element is genuinely gone', () => {
56
+ const el = document.createElement('button');
57
+ el.textContent = 'Delete';
58
+ document.body.appendChild(el);
59
+
60
+ const reg = new RefRegistry('snap-1');
61
+ reg.set('@e1', computeLocator(el, 'button'));
62
+
63
+ el.remove();
64
+ expect(reg.resolve('@e1')).toBeNull();
65
+ });
66
+
67
+ it('exposes the stored locator for diagnostics', () => {
68
+ const el = document.createElement('a');
69
+ el.textContent = 'Home';
70
+ document.body.appendChild(el);
71
+
72
+ const reg = new RefRegistry('snap-1');
73
+ reg.set('@e1', computeLocator(el, 'link'));
74
+
75
+ const locator = reg.getLocator('@e1');
76
+ expect(locator?.role).toBe('link');
77
+ expect(locator?.name).toBe('Home');
78
+ expect(reg.getLocator('@e9')).toBeUndefined();
79
+ });
24
80
  });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Re-locatable ref descriptors.
3
+ *
4
+ * A snapshot is captured when a chat message is sent; the AI's
5
+ * `point` directive arrives seconds later. In between, React re-renders
6
+ * the page — the chat panel expands, content streams in, lists update —
7
+ * and React recreates DOM nodes. Any `HTMLElement` reference frozen at
8
+ * capture time is then detached (`isConnected === false`) and useless.
9
+ *
10
+ * So a ref must NOT be stored as a node pointer. It is stored as a
11
+ * `RefLocator`: a description rich enough to FIND the element again in
12
+ * whatever DOM exists at resolve time. `resolveLocator` runs a live
13
+ * query against the current `document` — it never dereferences a stale
14
+ * pointer.
15
+ */
16
+
17
+ import { accessibleName } from '../capture/accessible-name';
18
+ import { tagName } from '../capture/dom-utils';
19
+ import type { CSTInteractiveRole } from '../cst/types';
20
+
21
+ /**
22
+ * A description that can re-find an interactive element in the live DOM.
23
+ *
24
+ * `role` + `name` is the primary locator: the AI cited an element by the
25
+ * accessible name the CST gave it, so "the visible `link` named 'Browse
26
+ * Catalog'" survives node-identity changes and even position shifts.
27
+ * `selector` is a structural tiebreaker used to disambiguate when
28
+ * several elements share the same role + name.
29
+ */
30
+ export interface RefLocator {
31
+ /** Interactive role, as classified by the CST walk. */
32
+ role: CSTInteractiveRole;
33
+ /** Accessible name — the label the AI was shown for this element. */
34
+ name: string;
35
+ /** lowercased tag name, used to filter candidates. */
36
+ tag: string;
37
+ /**
38
+ * A CSS selector that re-finds the element. Built from a stable
39
+ * attribute (`id` / `data-testid` / `name`) when one exists, else a
40
+ * structural `nth-of-type` path from the nearest id'd ancestor or
41
+ * `<body>`. A tiebreaker, not the source of truth — React can
42
+ * recreate nodes and shift positions, breaking the path.
43
+ */
44
+ selector: string;
45
+ }
46
+
47
+ /** Attributes that, when present, give a stable unique-ish selector. */
48
+ const STABLE_ATTRS = ['data-testid', 'id', 'name'] as const;
49
+
50
+ /** True when a selector matches exactly one element in `document`. */
51
+ function isUnique(selector: string): boolean {
52
+ try {
53
+ return document.querySelectorAll(selector).length === 1;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /** Escape a value for use inside a CSS attribute selector. */
60
+ function cssEscape(value: string): string {
61
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
62
+ return CSS.escape(value);
63
+ }
64
+ return value.replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`);
65
+ }
66
+
67
+ /**
68
+ * Build a structural `nth-of-type` path from the nearest id'd ancestor
69
+ * (or `<body>`) down to `el`. Survives re-renders that recreate nodes
70
+ * but keep the same tag structure and ordering — the common React case.
71
+ */
72
+ function structuralPath(el: HTMLElement): string {
73
+ const parts: string[] = [];
74
+ let node: Element | null = el;
75
+ while (node && node !== document.body && node !== document.documentElement) {
76
+ const id = node.getAttribute('id');
77
+ if (id) {
78
+ parts.unshift(`#${cssEscape(id)}`);
79
+ return parts.join(' > ');
80
+ }
81
+ const tag = tagName(node);
82
+ const parent: Element | null = node.parentElement;
83
+ if (!parent) {
84
+ parts.unshift(tag);
85
+ break;
86
+ }
87
+ let index = 1;
88
+ for (const sib of Array.from(parent.children)) {
89
+ if (sib === node) break;
90
+ if (tagName(sib) === tag) index += 1;
91
+ }
92
+ parts.unshift(`${tag}:nth-of-type(${index})`);
93
+ node = parent;
94
+ }
95
+ return parts.length ? `body > ${parts.join(' > ')}` : 'body';
96
+ }
97
+
98
+ /**
99
+ * Compute a re-locatable descriptor for an interactive element at
100
+ * capture time. `role` is already known from classification; `name` is
101
+ * derived with the same accessible-name logic the CST walk uses, so the
102
+ * locator's name matches exactly what the AI was shown.
103
+ */
104
+ export function computeLocator(
105
+ el: HTMLElement,
106
+ role: CSTInteractiveRole,
107
+ ): RefLocator {
108
+ let selector = '';
109
+ for (const attr of STABLE_ATTRS) {
110
+ const raw = el.getAttribute(attr);
111
+ if (!raw) continue;
112
+ const candidate =
113
+ attr === 'id'
114
+ ? `#${cssEscape(raw)}`
115
+ : `${tagName(el)}[${attr}="${cssEscape(raw)}"]`;
116
+ if (isUnique(candidate)) {
117
+ selector = candidate;
118
+ break;
119
+ }
120
+ }
121
+ if (!selector) selector = structuralPath(el);
122
+
123
+ return {
124
+ role,
125
+ name: accessibleName(el),
126
+ tag: tagName(el),
127
+ selector,
128
+ };
129
+ }
130
+
131
+ /** True when an element is rendered (has layout) — prefers visible hits. */
132
+ function isRendered(el: HTMLElement): boolean {
133
+ if (!el.isConnected) return false;
134
+ // offsetParent is null for display:none; width/height covers fixed
135
+ // and body-level elements that legitimately have no offsetParent.
136
+ return (
137
+ el.offsetParent !== null ||
138
+ el.getClientRects().length > 0
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Find a connected element matching a locator against the live DOM.
144
+ *
145
+ * Resolution order:
146
+ * 1. the structural / attribute selector — if it yields exactly one
147
+ * connected element, trust it;
148
+ * 2. the role + accessible name — scan candidate tags, match the live
149
+ * accessible name, prefer a rendered element. This is the resilient
150
+ * path: it does not depend on node identity or position.
151
+ * 3. give up — the element is genuinely gone (stale ref).
152
+ */
153
+ export function resolveLocator(locator: RefLocator): HTMLElement | null {
154
+ // 1. Selector — a precise hit when structure held.
155
+ try {
156
+ const hits = document.querySelectorAll<HTMLElement>(locator.selector);
157
+ if (hits.length === 1 && hits[0].isConnected) return hits[0];
158
+ } catch {
159
+ // Malformed selector — fall through to the role+name match.
160
+ }
161
+
162
+ // 2. Role + accessible name — robust to node recreation.
163
+ const candidates = collectByRoleName(locator);
164
+ if (candidates.length === 0) return null;
165
+ return candidates.find(isRendered) ?? candidates[0];
166
+ }
167
+
168
+ /** Tags worth scanning for a given role when matching by role + name. */
169
+ function tagsForRole(role: CSTInteractiveRole): string[] {
170
+ switch (role) {
171
+ case 'link':
172
+ return ['a'];
173
+ case 'button':
174
+ return ['button', 'a', 'input'];
175
+ case 'textbox':
176
+ case 'searchbox':
177
+ return ['input', 'textarea'];
178
+ case 'checkbox':
179
+ case 'radio':
180
+ case 'slider':
181
+ case 'spinbutton':
182
+ return ['input'];
183
+ case 'select':
184
+ case 'combobox':
185
+ return ['select'];
186
+ case 'textarea':
187
+ return ['textarea'];
188
+ default:
189
+ return ['button', 'a', 'input'];
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Collect connected elements whose role + accessible name match the
195
+ * locator. Also scans `[role=...]` so ARIA-roled elements are found.
196
+ */
197
+ function collectByRoleName(locator: RefLocator): HTMLElement[] {
198
+ const seen = new Set<HTMLElement>();
199
+ const selectors = [
200
+ ...tagsForRole(locator.role),
201
+ `[role="${locator.role}"]`,
202
+ ];
203
+ const out: HTMLElement[] = [];
204
+ for (const sel of selectors) {
205
+ let nodes: NodeListOf<HTMLElement>;
206
+ try {
207
+ nodes = document.querySelectorAll<HTMLElement>(sel);
208
+ } catch {
209
+ continue;
210
+ }
211
+ for (const node of Array.from(nodes)) {
212
+ if (seen.has(node) || !node.isConnected) continue;
213
+ seen.add(node);
214
+ if (accessibleName(node) === locator.name) out.push(node);
215
+ }
216
+ }
217
+ return out;
218
+ }
@@ -1,37 +1,52 @@
1
1
  /**
2
- * Ref registry — maps CST ref ids (`@e4`) back to DOM elements.
2
+ * Ref registry — maps CST ref ids (`@e4`) to a way of re-finding the
3
+ * element in the live DOM.
3
4
  *
4
- * Built during the DOM walk; kept after serialization so AI-driven
5
- * highlight/focus directives can resolve a ref to a live element.
6
- * Elements are held weakly so a removed node can be garbage-collected —
7
- * an unresolvable ref is the "stale" case.
5
+ * The registry deliberately does NOT hold `HTMLElement` references.
6
+ * Between snapshot capture (chat-message send) and directive
7
+ * application (the `directive` SSE event, seconds later), React
8
+ * re-renders the page and recreates DOM nodes — any frozen node pointer
9
+ * is detached by then and resolves to nothing. Instead each ref stores
10
+ * a `RefLocator` and `resolve()` runs a live-DOM query at call time.
8
11
  */
9
12
 
10
13
  import type { CSTRefId } from '../cst/types';
14
+ import { resolveLocator, type RefLocator } from './locator';
11
15
 
12
16
  /**
13
- * A per-snapshot ref → element map. Elements are held weakly so a
14
- * removed node can be garbage-collectedan unresolvable ref is the
15
- * "stale" case.
17
+ * A per-snapshot `ref → RefLocator` map. Locators are inert data, so
18
+ * the registry stays valid across re-renders`resolve()` re-queries
19
+ * the current DOM every call rather than dereferencing a stale node.
16
20
  */
17
21
  export class RefRegistry {
18
22
  /** Unique id of the snapshot these refs belong to. */
19
23
  readonly snapshotId: string;
20
24
 
21
- private readonly map = new Map<CSTRefId, WeakRef<HTMLElement>>();
25
+ private readonly map = new Map<CSTRefId, RefLocator>();
22
26
 
23
27
  constructor(snapshotId: string) {
24
28
  this.snapshotId = snapshotId;
25
29
  }
26
30
 
27
- /** Register a ref → element pair (called during the walk). */
28
- set(ref: CSTRefId, element: HTMLElement): void {
29
- this.map.set(ref, new WeakRef(element));
31
+ /** Register a ref → locator pair (called during the walk). */
32
+ set(ref: CSTRefId, locator: RefLocator): void {
33
+ this.map.set(ref, locator);
30
34
  }
31
35
 
32
- /** Resolve a ref to its element, or null if gone / unknown (stale). */
36
+ /**
37
+ * Resolve a ref to a live element, or null if it cannot be found in
38
+ * the current DOM (stale). Runs a fresh query every call — safe to
39
+ * call long after capture, across any number of re-renders.
40
+ */
33
41
  resolve(ref: CSTRefId): HTMLElement | null {
34
- return this.map.get(ref)?.deref() ?? null;
42
+ const locator = this.map.get(ref);
43
+ if (!locator) return null;
44
+ return resolveLocator(locator);
45
+ }
46
+
47
+ /** The stored locator for a ref, if known (mainly for diagnostics). */
48
+ getLocator(ref: CSTRefId): RefLocator | undefined {
49
+ return this.map.get(ref);
35
50
  }
36
51
 
37
52
  /** Whether a ref is known to this snapshot (regardless of liveness). */
@@ -57,7 +57,7 @@ export function MyChat() {
57
57
  - **Language flag button.** `headerSlots.languagePicker: true` slots a 28×28 country flag into the dock header — opens a searchable `<Combobox>` with 66 BCP-47 tags from the Chrome Web Speech catalogue. Selection persists via `useSpeechPrefs`, picked up by every `useSpeechRecognition` downstream. (Raw `<ChatHeaderLanguageButton>` still exported for custom shells.)
58
58
  - **Auto-focus on stream end.** `<ChatProvider>` re-focuses the registered composer on the streaming → idle edge — type → send → read → keep typing without reaching for the mouse. Works for **every** usage pattern (`ChatRoot`, hand-rolled `ChatProvider` + `Composer`, headless), not just `ChatRoot`. Opt out with `<ChatProvider autoFocusOnStreamEnd={false}>`. The standalone `useAutoFocusOnStreamEnd()` hook is still exported for advanced cases (focus a non-composer target, drive `isStreaming` from your own store).
59
59
  - **Page-context snapshot.** Optional `getDynamicMetadata` contributor on `<ChatProvider>` / `<ChatRoot>` — called fresh at send time, merged into transport `metadata`. Pairs with the `page-snapshot` engine (`src/lib/page-snapshot`) to attach a token-efficient, redacted snapshot of the page the user is looking at, so the assistant can answer in context. The snapshot rides a separate `metadata` field, never the message text.
60
- - **AI highlight directives.** `highlight/` — when the assistant returns `point` directives, `<HighlightOverlay>` resolves each CST ref to a live element and draws an SVG-mask spotlight (optionally moves focus). Read-only: it points at the UI, never changes data. See [`highlight/README.md`](./highlight/README.md).
60
+ - **AI bridge directives.** `bridge/` — when the assistant returns `point` directives, `<HighlightOverlay>` resolves each CST ref to a live element and draws an SVG-mask spotlight (optionally moves focus). The bridge also exposes a command registry the AI drives the page through. Read-only by default: it points at the UI, never changes data. See [`bridge/README.md`](./bridge/README.md).
61
61
  - **Centralized colors.** Role-aware className tokens (`BUBBLE_SURFACE` / `ANCHOR` / `TOGGLE` / `DESTRUCTIVE_SURFACE`) + hooks (`useChatBubbleStyles`, `useChatRoleStyles`, `useChatDestructiveStyles`).
62
62
  - **Responsive.** FAB `size='responsive'` (default): phone → `sm`, tablet → `md`, desktop → `lg`. Side mode is desktop-only and falls back to popover below `lg`.
63
63
  - **Mobile fullscreen.** Dock auto-fills viewport below 768px via `useIsMobile`. Heights use `dvh/svh/lvh` so iOS Safari URL bar doesn't clip the chat.
@@ -12,7 +12,30 @@ export const CSS_VARS = {
12
12
  reserve: '--djc-chat-reserve',
13
13
  } as const;
14
14
 
15
- export const DEFAULT_Z_INDEX = 9000;
15
+ /**
16
+ * Z-index tier for the floating chat surface.
17
+ *
18
+ * The chat dock is page furniture — a peer of page content, not a modal.
19
+ * It must sit ABOVE the page yet BELOW every `@djangocfg/ui-core` overlay
20
+ * (sheet 200, drawer 500, dialog 600, anchored overlays 700) so that any
21
+ * dialog — including one launched from inside the chat — always wins.
22
+ *
23
+ * dock 100 the chat surface
24
+ * companion 99 FAB / greeting / unread preview (behind the open dock)
25
+ * tooltip 101 chat-header tooltips (just above the dock, still
26
+ * below ui-core's modal/overlay tiers)
27
+ */
28
+ export const Z_INDEX = {
29
+ /** The chat dock surface. */
30
+ dock: 100,
31
+ /** FAB, greeting bubble and unread preview — sit just below the dock. */
32
+ companion: 99,
33
+ /** Tooltips portaled out of the chat header — just above the dock. */
34
+ tooltip: 101,
35
+ } as const;
36
+
37
+ /** @deprecated Use {@link Z_INDEX.dock}. Kept for the public API surface. */
38
+ export const DEFAULT_Z_INDEX = Z_INDEX.dock;
16
39
 
17
40
  export const LIMITS = {
18
41
  /** Max characters per single message. */