@fastbrowse/darwin-arm64 0.0.0 → 0.5.19

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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +33 -0
  3. package/README.md +4 -1
  4. package/fastbrowse/_internal/Python +0 -0
  5. package/fastbrowse/_internal/Python.framework/Python +0 -0
  6. package/fastbrowse/_internal/Python.framework/Resources/Info.plist +30 -0
  7. package/fastbrowse/_internal/Python.framework/Versions/3.13/Python +0 -0
  8. package/fastbrowse/_internal/Python.framework/Versions/3.13/Resources/Info.plist +30 -0
  9. package/fastbrowse/_internal/Python.framework/Versions/3.13/_CodeSignature/CodeResources +128 -0
  10. package/fastbrowse/_internal/Python.framework/Versions/Current/Python +0 -0
  11. package/fastbrowse/_internal/Python.framework/Versions/Current/Resources/Info.plist +30 -0
  12. package/fastbrowse/_internal/Python.framework/Versions/Current/_CodeSignature/CodeResources +128 -0
  13. package/fastbrowse/_internal/base_library.zip +0 -0
  14. package/fastbrowse/_internal/certifi/cacert.pem +3959 -0
  15. package/fastbrowse/_internal/certifi/py.typed +0 -0
  16. package/fastbrowse/_internal/fastbrowse/browser/autoconsent/LICENSE +373 -0
  17. package/fastbrowse/_internal/fastbrowse/browser/autoconsent/SOURCE.txt +20 -0
  18. package/fastbrowse/_internal/fastbrowse/browser/autoconsent/autoconsent.standalone.js +3543 -0
  19. package/fastbrowse/_internal/fastbrowse/browser/capture.js +323 -0
  20. package/fastbrowse/_internal/fastbrowse/browser/snapshot.js +839 -0
  21. package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/INSTALLER +1 -0
  22. package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/METADATA +539 -0
  23. package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/RECORD +14 -0
  24. package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/REQUESTED +0 -0
  25. package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/WHEEL +4 -0
  26. package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/direct_url.json +1 -0
  27. package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/entry_points.txt +3 -0
  28. package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/licenses/LICENSE +21 -0
  29. package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/licenses/NOTICE +33 -0
  30. package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/uv_build.json +1 -0
  31. package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/uv_cache.json +1 -0
  32. package/fastbrowse/_internal/libcrypto.3.dylib +0 -0
  33. package/fastbrowse/_internal/libncurses.6.dylib +0 -0
  34. package/fastbrowse/_internal/libssl.3.dylib +0 -0
  35. package/fastbrowse/_internal/pydantic-2.13.4.dist-info/INSTALLER +1 -0
  36. package/fastbrowse/_internal/pydantic-2.13.4.dist-info/METADATA +1294 -0
  37. package/fastbrowse/_internal/pydantic-2.13.4.dist-info/RECORD +113 -0
  38. package/fastbrowse/_internal/pydantic-2.13.4.dist-info/REQUESTED +0 -0
  39. package/fastbrowse/_internal/pydantic-2.13.4.dist-info/WHEEL +4 -0
  40. package/fastbrowse/_internal/pydantic-2.13.4.dist-info/licenses/LICENSE +21 -0
  41. package/fastbrowse/_internal/pydantic_core/_pydantic_core.cpython-313-darwin.so +0 -0
  42. package/fastbrowse/_internal/python3.13/lib-dynload/_asyncio.cpython-313-darwin.so +0 -0
  43. package/fastbrowse/_internal/python3.13/lib-dynload/_bisect.cpython-313-darwin.so +0 -0
  44. package/fastbrowse/_internal/python3.13/lib-dynload/_blake2.cpython-313-darwin.so +0 -0
  45. package/fastbrowse/_internal/python3.13/lib-dynload/_bz2.cpython-313-darwin.so +0 -0
  46. package/fastbrowse/_internal/python3.13/lib-dynload/_codecs_cn.cpython-313-darwin.so +0 -0
  47. package/fastbrowse/_internal/python3.13/lib-dynload/_codecs_hk.cpython-313-darwin.so +0 -0
  48. package/fastbrowse/_internal/python3.13/lib-dynload/_codecs_iso2022.cpython-313-darwin.so +0 -0
  49. package/fastbrowse/_internal/python3.13/lib-dynload/_codecs_jp.cpython-313-darwin.so +0 -0
  50. package/fastbrowse/_internal/python3.13/lib-dynload/_codecs_kr.cpython-313-darwin.so +0 -0
  51. package/fastbrowse/_internal/python3.13/lib-dynload/_codecs_tw.cpython-313-darwin.so +0 -0
  52. package/fastbrowse/_internal/python3.13/lib-dynload/_contextvars.cpython-313-darwin.so +0 -0
  53. package/fastbrowse/_internal/python3.13/lib-dynload/_csv.cpython-313-darwin.so +0 -0
  54. package/fastbrowse/_internal/python3.13/lib-dynload/_ctypes.cpython-313-darwin.so +0 -0
  55. package/fastbrowse/_internal/python3.13/lib-dynload/_curses.cpython-313-darwin.so +0 -0
  56. package/fastbrowse/_internal/python3.13/lib-dynload/_datetime.cpython-313-darwin.so +0 -0
  57. package/fastbrowse/_internal/python3.13/lib-dynload/_decimal.cpython-313-darwin.so +0 -0
  58. package/fastbrowse/_internal/python3.13/lib-dynload/_hashlib.cpython-313-darwin.so +0 -0
  59. package/fastbrowse/_internal/python3.13/lib-dynload/_heapq.cpython-313-darwin.so +0 -0
  60. package/fastbrowse/_internal/python3.13/lib-dynload/_interpqueues.cpython-313-darwin.so +0 -0
  61. package/fastbrowse/_internal/python3.13/lib-dynload/_interpreters.cpython-313-darwin.so +0 -0
  62. package/fastbrowse/_internal/python3.13/lib-dynload/_json.cpython-313-darwin.so +0 -0
  63. package/fastbrowse/_internal/python3.13/lib-dynload/_lzma.cpython-313-darwin.so +0 -0
  64. package/fastbrowse/_internal/python3.13/lib-dynload/_md5.cpython-313-darwin.so +0 -0
  65. package/fastbrowse/_internal/python3.13/lib-dynload/_multibytecodec.cpython-313-darwin.so +0 -0
  66. package/fastbrowse/_internal/python3.13/lib-dynload/_multiprocessing.cpython-313-darwin.so +0 -0
  67. package/fastbrowse/_internal/python3.13/lib-dynload/_opcode.cpython-313-darwin.so +0 -0
  68. package/fastbrowse/_internal/python3.13/lib-dynload/_pickle.cpython-313-darwin.so +0 -0
  69. package/fastbrowse/_internal/python3.13/lib-dynload/_posixshmem.cpython-313-darwin.so +0 -0
  70. package/fastbrowse/_internal/python3.13/lib-dynload/_posixsubprocess.cpython-313-darwin.so +0 -0
  71. package/fastbrowse/_internal/python3.13/lib-dynload/_queue.cpython-313-darwin.so +0 -0
  72. package/fastbrowse/_internal/python3.13/lib-dynload/_random.cpython-313-darwin.so +0 -0
  73. package/fastbrowse/_internal/python3.13/lib-dynload/_scproxy.cpython-313-darwin.so +0 -0
  74. package/fastbrowse/_internal/python3.13/lib-dynload/_sha1.cpython-313-darwin.so +0 -0
  75. package/fastbrowse/_internal/python3.13/lib-dynload/_sha2.cpython-313-darwin.so +0 -0
  76. package/fastbrowse/_internal/python3.13/lib-dynload/_sha3.cpython-313-darwin.so +0 -0
  77. package/fastbrowse/_internal/python3.13/lib-dynload/_socket.cpython-313-darwin.so +0 -0
  78. package/fastbrowse/_internal/python3.13/lib-dynload/_ssl.cpython-313-darwin.so +0 -0
  79. package/fastbrowse/_internal/python3.13/lib-dynload/_statistics.cpython-313-darwin.so +0 -0
  80. package/fastbrowse/_internal/python3.13/lib-dynload/_struct.cpython-313-darwin.so +0 -0
  81. package/fastbrowse/_internal/python3.13/lib-dynload/_uuid.cpython-313-darwin.so +0 -0
  82. package/fastbrowse/_internal/python3.13/lib-dynload/_zoneinfo.cpython-313-darwin.so +0 -0
  83. package/fastbrowse/_internal/python3.13/lib-dynload/array.cpython-313-darwin.so +0 -0
  84. package/fastbrowse/_internal/python3.13/lib-dynload/binascii.cpython-313-darwin.so +0 -0
  85. package/fastbrowse/_internal/python3.13/lib-dynload/fcntl.cpython-313-darwin.so +0 -0
  86. package/fastbrowse/_internal/python3.13/lib-dynload/grp.cpython-313-darwin.so +0 -0
  87. package/fastbrowse/_internal/python3.13/lib-dynload/math.cpython-313-darwin.so +0 -0
  88. package/fastbrowse/_internal/python3.13/lib-dynload/mmap.cpython-313-darwin.so +0 -0
  89. package/fastbrowse/_internal/python3.13/lib-dynload/pyexpat.cpython-313-darwin.so +0 -0
  90. package/fastbrowse/_internal/python3.13/lib-dynload/readline.cpython-313-darwin.so +0 -0
  91. package/fastbrowse/_internal/python3.13/lib-dynload/resource.cpython-313-darwin.so +0 -0
  92. package/fastbrowse/_internal/python3.13/lib-dynload/select.cpython-313-darwin.so +0 -0
  93. package/fastbrowse/_internal/python3.13/lib-dynload/syslog.cpython-313-darwin.so +0 -0
  94. package/fastbrowse/_internal/python3.13/lib-dynload/termios.cpython-313-darwin.so +0 -0
  95. package/fastbrowse/_internal/python3.13/lib-dynload/unicodedata.cpython-313-darwin.so +0 -0
  96. package/fastbrowse/_internal/python3.13/lib-dynload/zlib.cpython-313-darwin.so +0 -0
  97. package/fastbrowse/_internal/setuptools/_vendor/jaraco/text/Lorem ipsum.txt +2 -0
  98. package/fastbrowse/_internal/websockets/speedups.cpython-313-darwin.so +0 -0
  99. package/fastbrowse/_internal/websockets-17.1.dist-info/INSTALLER +1 -0
  100. package/fastbrowse/_internal/websockets-17.1.dist-info/METADATA +170 -0
  101. package/fastbrowse/_internal/websockets-17.1.dist-info/RECORD +65 -0
  102. package/fastbrowse/_internal/websockets-17.1.dist-info/REQUESTED +0 -0
  103. package/fastbrowse/_internal/websockets-17.1.dist-info/WHEEL +6 -0
  104. package/fastbrowse/_internal/websockets-17.1.dist-info/entry_points.txt +2 -0
  105. package/fastbrowse/_internal/websockets-17.1.dist-info/licenses/LICENSE +24 -0
  106. package/fastbrowse/_internal/websockets-17.1.dist-info/top_level.txt +1 -0
  107. package/fastbrowse/fastbrowse +0 -0
  108. package/package.json +21 -4
@@ -0,0 +1,839 @@
1
+ // Ported from jev-ultrafast (MIT): jev_ultrafast/snapshot.js.
2
+ // Atomic single-evaluate observation of one frame's controls, viewport text, and a freshness fingerprint.
3
+ // Adapted to fastbrowse's Control shape (role + operation set, not per-kind actions) and to traverse
4
+ // open shadow roots. Runs once per frame session (main frame or an OOPIF); the Python side merges frames.
5
+ // biome-ignore format: Python appends mode arguments to this parenthesized expression.
6
+ (mode => {
7
+ // Form context is repeated for each submit-capable input in safety prompts.
8
+ const FORM_TEXT_CHARS = 2000;
9
+ // Freshness guards bound comparison work on large cards; this text is never sent to a model.
10
+ const GUARD_TEXT_CHARS = 6000;
11
+ // Twin controls repeat their card heading, so keep its excerpt shorter than the control itself.
12
+ const CONTROL_CONTEXT_CHARS = 120;
13
+ // A hover target can be a whole card whose text would crowd out the other controls.
14
+ const HOVER_LABEL_CHARS = 200;
15
+ // Each hover rule fans out to DOM queries, so bound stylesheet work per observation.
16
+ const MAX_HOVER_RULES = 200;
17
+ // Hover targets supplement the actionable controls rather than fill their observation budget.
18
+ const MAX_HOVER_TARGETS = 40;
19
+ // Each candidate checks hidden descendants, which can stall a dense page's observation.
20
+ const MAX_HOVER_CHECKS = 400;
21
+ const excerpt = (text, cap) =>
22
+ text.length <= cap ? text : `${text.slice(0, cap)} [${text.length - cap} characters omitted]`;
23
+ // A transparent native checkbox is the one control TodoMVC shows, so only its own opacity is excused: a
24
+ // transparent ancestor still hides it. Walked by style, not layout box, as a `display: contents` parent
25
+ // generates no box yet hides nothing.
26
+ const nativeChoice = e => e.tagName === 'INPUT' && ['checkbox', 'radio'].includes(e.type);
27
+ const shownThrough = e => {
28
+ for (let a = e.parentElement ?? e.getRootNode().host; a; a = a.parentElement ?? a.getRootNode().host)
29
+ if (a.ownerDocument.defaultView.getComputedStyle(a).opacity === '0') return false;
30
+ return true;
31
+ };
32
+ const registry = (window.__fastbrowse ||= { ids: new WeakMap(), nodes: new Map(), next: 1 });
33
+ // What was offered to be acted on; `ids` also names hover targets and fingerprinted nodes.
34
+ registry.controls ||= new WeakSet();
35
+ // A visible loading indicator means the page is still fetching what it will draw, which network idle and a
36
+ // quiet DOM both report as settled: a spinner mutates nothing while it spins. Named classes are a heuristic
37
+ // and deliberately so -- the wait on them is bounded and expires into proceeding, so a false match costs
38
+ // time and never correctness. Hidden indicators are ignored because most pages keep one in the DOM always.
39
+ const LOADING =
40
+ '[aria-busy="true"],[role="progressbar"],progress:not([value]),' +
41
+ ['spinner', 'loading', 'loader', 'skeleton', 'shimmer']
42
+ .flatMap(name => [`[class*="${name}" i]`, `[id*="${name}" i]`])
43
+ .join(',');
44
+ // Only an indicator on screen is waited on. GitHub keeps an empty fixed progress bar and lazy skeletons below
45
+ // the fold, which load only once scrolled to: waiting on them spent the full wait on every GitHub page.
46
+ const showing = e => {
47
+ if (!e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false;
48
+ const r = e.getBoundingClientRect();
49
+ const view = e.ownerDocument.defaultView || window;
50
+ return (
51
+ r.width > 0 && r.height > 0 && r.bottom > 0 && r.right > 0 && r.top < view.innerHeight && r.left < view.innerWidth
52
+ );
53
+ };
54
+ if (!registry.track) {
55
+ const roots = new WeakSet();
56
+ const changed = () => {
57
+ registry.lastMutation = performance.now();
58
+ };
59
+ const observer = new MutationObserver(changed);
60
+ registry.track = root => {
61
+ if (roots.has(root)) return;
62
+ roots.add(root);
63
+ observer.observe(root, { subtree: true, childList: true, characterData: true, attributes: true });
64
+ // Input handlers can defer a navigation or redraw without mutating yet. Start the quiet clock at the
65
+ // event, so the reply's travel time counts toward settling even when the action changes nothing.
66
+ for (const event of [
67
+ 'input',
68
+ 'change',
69
+ 'scroll',
70
+ 'wheel',
71
+ 'pointermove',
72
+ 'pointerdown',
73
+ 'pointerup',
74
+ 'keydown',
75
+ 'keyup',
76
+ ])
77
+ root.addEventListener(event, changed, true);
78
+ changed();
79
+ };
80
+ registry.track(document);
81
+ }
82
+ if (mode === 'fingerprint') {
83
+ let hash = 2166136261;
84
+ let loading = false;
85
+ const hashText = text => {
86
+ for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
87
+ };
88
+ const include = root => {
89
+ registry.track(root);
90
+ loading ||= [...root.querySelectorAll(LOADING)].some(showing);
91
+ const text = root.body?.innerText ?? root.textContent ?? '';
92
+ hashText(text);
93
+ // innerText omits shadow trees and child documents even when their content is visible.
94
+ for (const e of root.querySelectorAll('*')) {
95
+ // A filter can change only its checkmark, so text alone reported "Nonstop only" as a no-op.
96
+ // Field values stay out: the run judges fills by the value it wrote, even when a popup changes.
97
+ // Only rendered controls: a hidden carousel's aria-selected dots churn on their own and would read as
98
+ // progress.
99
+ if (
100
+ ('checked' in e || 'selected' in e || e.hasAttribute('aria-checked') || e.hasAttribute('aria-selected')) &&
101
+ e.checkVisibility({ checkOpacity: !nativeChoice(e), checkVisibilityCSS: true }) &&
102
+ (!nativeChoice(e) || shownThrough(e))
103
+ )
104
+ hashText(
105
+ JSON.stringify([
106
+ e.checked ?? null,
107
+ e.selected ?? null,
108
+ e.getAttribute('aria-checked'),
109
+ e.getAttribute('aria-selected'),
110
+ ]),
111
+ );
112
+ if (e.shadowRoot) include(e.shadowRoot);
113
+ if (e.tagName === 'IFRAME' || e.tagName === 'FRAME') {
114
+ let inner = null;
115
+ try {
116
+ inner = e.contentDocument;
117
+ } catch {
118
+ inner = null;
119
+ }
120
+ if (inner?.body) include(inner);
121
+ }
122
+ }
123
+ };
124
+ include(document);
125
+ return {
126
+ fingerprint: location.href + '|' + document.title + '|' + (hash >>> 0) + '|' + scrollY,
127
+ ready: document.readyState === 'interactive' || document.readyState === 'complete',
128
+ quietFor: performance.now() - registry.lastMutation,
129
+ hidden: document.hidden,
130
+ loading,
131
+ };
132
+ }
133
+ if (!document.body) return null;
134
+ const identity = e => {
135
+ if (!registry.ids.has(e)) registry.ids.set(e, registry.next++);
136
+ const id = registry.ids.get(e);
137
+ registry.nodes.set(id, e);
138
+ return id;
139
+ };
140
+ for (const [id, e] of registry.nodes) if (!e.isConnected) registry.nodes.delete(id);
141
+
142
+ const safe = e => e.type !== 'hidden';
143
+ // Password and agent-typed secret values never leave the page: only their length is observed.
144
+ const secret = e => e.type === 'password' || e.dataset?.fastbrowseSecret === '1';
145
+ const reveal = e => (typeof e.value !== 'string' ? null : secret(e) ? '•'.repeat(e.value.length) : e.value);
146
+ const visible = e => {
147
+ // Todo lists paint the checkmark beside a transparent native input without associating a label.
148
+ // The input still receives clicks; opacity alone must not remove the only control for that row.
149
+ // A transparent input with a visible label of its own keeps the label as its click target: TodoMVC's
150
+ // "Mark all as complete" input is 1px and off screen, and only its label can be clicked.
151
+ const choice =
152
+ nativeChoice(e) &&
153
+ e.ownerDocument.defaultView.getComputedStyle(e).pointerEvents !== 'none' &&
154
+ ![...(e.labels || [])].some(l => l.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }));
155
+ if (
156
+ e.closest('[aria-hidden="true"],[inert]') ||
157
+ !e.checkVisibility({ checkOpacity: !choice, checkVisibilityCSS: true }) ||
158
+ (choice && !shownThrough(e))
159
+ )
160
+ return false;
161
+ if (!choice) return true;
162
+ const r = e.getBoundingClientRect();
163
+ return r.width > 0 && r.height > 0 && !e.matches(':disabled') && !e.closest('[aria-disabled="true"]');
164
+ };
165
+ registry.visible = visible;
166
+ // Styled checkboxes and radios often hide the native input. Its visible label is the click
167
+ // target, but the input still owns the checked/disabled state and must participate in freshness.
168
+ const sourceOf = e => (e.tagName === 'LABEL' && ['checkbox', 'radio'].includes(e.control?.type) ? e.control : e);
169
+
170
+ // Their text is code, not a name: Amazon nests a <style> inside a result card's link.
171
+ const CODE = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
172
+ const labelOf = (e, seen = new Set()) => {
173
+ if (!e || seen.has(e)) return '';
174
+ seen.add(e);
175
+ const root = typeof e.getRootNode === 'function' ? e.getRootNode() : document;
176
+ const byId = id => (root.getElementById ? root.getElementById(id) : document.getElementById(id));
177
+ const referenced = (e.getAttribute('aria-labelledby') || '')
178
+ .split(/\s+/)
179
+ .map(id => labelOf(byId(id), seen))
180
+ .filter(Boolean)
181
+ .join(' ');
182
+ return (
183
+ referenced ||
184
+ e.getAttribute('aria-label') ||
185
+ [...(e.labels || [])]
186
+ .map(l => labelOf(l, seen))
187
+ .filter(Boolean)
188
+ .join(' ') ||
189
+ (['button', 'submit', 'reset'].includes(e.type) ? e.value : '') ||
190
+ e.getAttribute('alt') ||
191
+ (e.tagName === 'INPUT'
192
+ ? ''
193
+ : [...e.childNodes]
194
+ .map(n =>
195
+ n.nodeType === 3
196
+ ? n.textContent
197
+ : n.nodeType === 1 && !CODE.has(n.tagName) && n.getAttribute('aria-hidden') !== 'true'
198
+ ? labelOf(n, seen)
199
+ : '',
200
+ )
201
+ .join(' ')
202
+ .trim()) ||
203
+ e.getAttribute('title') ||
204
+ e.getAttribute('placeholder') ||
205
+ ''
206
+ );
207
+ };
208
+
209
+ const ARIA_ROLES = [
210
+ 'button',
211
+ 'link',
212
+ 'checkbox',
213
+ 'radio',
214
+ 'switch',
215
+ 'tab',
216
+ 'menuitem',
217
+ 'menuitemradio',
218
+ 'option',
219
+ 'gridcell',
220
+ 'combobox',
221
+ 'textbox',
222
+ 'searchbox',
223
+ 'spinbutton',
224
+ ];
225
+ // jQuery UI's datepicker prev/next are anchors with no href, appended to <body> once the field is focused:
226
+ // they carry no destination, only a click handler, so they need naming here to be walked at all.
227
+ const SELECTOR =
228
+ 'a[href],a[data-handler][data-event="click"],a[onclick],a[role="button"],' +
229
+ 'button,input,textarea,select,summary,label,[contenteditable="true"],[draggable="true"],[ondrop],' +
230
+ ARIA_ROLES.map(role => `[role="${role}"]`).join(',');
231
+ // Types whose value is a calendar date, not free text: the field writer must produce the type's ISO shape
232
+ // and the native value setter, not typed keystrokes, is what a picker widget actually commits.
233
+ const DATE_TYPES = ['date', 'datetime-local', 'month', 'week', 'time'];
234
+
235
+ const roleOf = e => {
236
+ if (sourceOf(e) !== e) return sourceOf(e).type;
237
+ const explicit = e.getAttribute('role');
238
+ if (ARIA_ROLES.includes(explicit)) return explicit;
239
+ if (e.tagName === 'BUTTON' || e.tagName === 'SUMMARY') return 'button';
240
+ // An anchor with nowhere to go acts through its click handler alone, like the jQuery UI datepicker's
241
+ // Next/Prev: a destination is what makes it a link, so without one it is offered as a button instead.
242
+ if (e.tagName === 'A') return e.hasAttribute('href') ? 'link' : 'button';
243
+ if (e.tagName === 'SELECT') return 'combobox';
244
+ if (e.tagName === 'TEXTAREA' || e.isContentEditable) return 'textbox';
245
+ if (e.tagName === 'INPUT') {
246
+ if (e.type === 'file') return 'textbox';
247
+ if (['checkbox', 'radio'].includes(e.type)) return e.type;
248
+ if (['button', 'submit', 'reset', 'image'].includes(e.type)) return 'button';
249
+ if (e.type === 'search') return 'searchbox';
250
+ if (e.type === 'number') return 'spinbutton';
251
+ if (['text', 'email', 'url', 'tel', 'password', ...DATE_TYPES].includes(e.type)) return 'textbox';
252
+ }
253
+ // A bare draggable element or a declared drop target has no ARIA role of its own, yet it is a real
254
+ // element to act on. Name it generic rather than null so the control walk does not drop it before the
255
+ // drag it offers can be seen.
256
+ if (e.getAttribute('draggable') === 'true' || e.hasAttribute('ondrop')) return 'generic';
257
+ return null;
258
+ };
259
+
260
+ const submitSemantics = e => {
261
+ const form = e.form;
262
+ const implicit = [
263
+ 'text',
264
+ 'search',
265
+ 'url',
266
+ 'tel',
267
+ 'email',
268
+ 'password',
269
+ 'date',
270
+ 'month',
271
+ 'week',
272
+ 'time',
273
+ 'datetime-local',
274
+ 'number',
275
+ ];
276
+ if (!form || e.tagName !== 'INPUT' || !implicit.includes(e.type)) return null;
277
+ const submit = [...form.getRootNode().querySelectorAll('button,input')].find(
278
+ c => c.form === form && (c.type === 'submit' || c.type === 'image'),
279
+ );
280
+ if (submit?.matches(':disabled')) return null;
281
+ if (!submit && [...form.elements].filter(c => c.tagName === 'INPUT' && implicit.includes(c.type)).length > 1)
282
+ return null;
283
+ return JSON.stringify({
284
+ label: submit ? labelOf(submit) : labelOf(form),
285
+ method: submit?.getAttribute('formmethod') || form.method,
286
+ action: submit?.getAttribute('formaction') || form.action,
287
+ text: excerpt(form.innerText, FORM_TEXT_CHARS),
288
+ });
289
+ };
290
+
291
+ const framePath = doc => {
292
+ if (doc === document) return null;
293
+ const owner = doc.defaultView.frameElement;
294
+ return `${framePath(owner.ownerDocument) || 'root'}/${identity(owner)}`;
295
+ };
296
+ registry.framePath = framePath;
297
+ registry.submitSemantics = submitSemantics;
298
+
299
+ // Traverse light DOM plus any open shadow roots, and same-origin same-process nested iframes.
300
+ let inaccessible = 0;
301
+ function* walk(root) {
302
+ // A document observer cannot see mutations inside shadow roots or child documents.
303
+ registry.track(root);
304
+ for (const e of root.querySelectorAll(SELECTOR)) yield e;
305
+ for (const e of root.querySelectorAll('*')) {
306
+ if (e.shadowRoot) yield* walk(e.shadowRoot);
307
+ if (e.tagName === 'IFRAME' || e.tagName === 'FRAME') {
308
+ let inner = null;
309
+ try {
310
+ inner = e.contentDocument;
311
+ } catch {
312
+ inner = null;
313
+ }
314
+ if (inner && inner.body) yield* walk(inner);
315
+ else inaccessible++;
316
+ }
317
+ }
318
+ }
319
+
320
+ registry.guard = e => {
321
+ if (!e?.isConnected || !visible(e)) return null;
322
+ const source = sourceOf(e);
323
+ if (source !== e && (source.matches(':disabled') || source.closest('[aria-disabled="true"],[inert]'))) return null;
324
+ const scope = e.closest('form,dialog,[role="dialog"],article,li,tr,[role="row"]') || e.parentElement;
325
+ // An absent aria-disabled and "false" both mean enabled. Google Flights adds the "false" as it hydrates, and
326
+ // the raw attribute turned the run's first click stale.
327
+ return [
328
+ identity(e),
329
+ roleOf(e),
330
+ labelOf(e),
331
+ reveal(source),
332
+ source.checked ?? null,
333
+ e.selectedIndex ?? null,
334
+ e.readOnly ?? null,
335
+ e.matches(':disabled'),
336
+ e.getAttribute('aria-disabled') === 'true',
337
+ e.getAttribute('aria-expanded'),
338
+ e.getAttribute('aria-checked'),
339
+ e.getAttribute('aria-selected'),
340
+ e.getAttribute('href'),
341
+ scope?.innerText?.slice(0, GUARD_TEXT_CHARS) || '',
342
+ e.ownerDocument.location.origin,
343
+ e.ownerDocument.defaultView.performance.timeOrigin,
344
+ submitSemantics(e),
345
+ identity(source),
346
+ ];
347
+ };
348
+
349
+ const controls = [];
350
+ const fieldScope = e => {
351
+ if (!e.matches('input,textarea,select')) return null;
352
+ const form = e.form || e.closest('form,fieldset,[role="form"]');
353
+ if (form) return String(identity(form));
354
+ // Script-built forms can use a local group of labelled fields without an HTML form owner.
355
+ // Stop at document sections so a sidebar search never joins the content's fields.
356
+ for (
357
+ let p = e.parentElement;
358
+ p && !p.matches('body,main,article,section,aside,nav,header,footer');
359
+ p = p.parentElement
360
+ ) {
361
+ if (p.querySelectorAll('input:not([type=hidden]),textarea,select').length > 1) return String(identity(p));
362
+ }
363
+ return null;
364
+ };
365
+ for (const e of walk(document)) {
366
+ if (!safe(e) || !visible(e) || e.matches(':disabled') || e.closest('[aria-disabled="true"],[inert]')) continue;
367
+ const source = sourceOf(e);
368
+ if (
369
+ source !== e &&
370
+ (visible(source) || source.matches(':disabled') || source.closest('[aria-disabled="true"],[inert]'))
371
+ )
372
+ continue;
373
+ const r = e.getBoundingClientRect(),
374
+ x = r.x + r.width / 2,
375
+ y = r.y + r.height / 2,
376
+ rname = roleOf(e);
377
+ if (!rname || r.width <= 0 || r.height <= 0 || x < 0 || x >= innerWidth) continue;
378
+ if (rname === 'gridcell' && e.querySelector('button,[role="button"]')) continue;
379
+ const id = identity(e);
380
+ registry.controls.add(e);
381
+ const base = {
382
+ id,
383
+ role: rname,
384
+ label: labelOf(e) || rname,
385
+ offscreen: y < 0 || y >= innerHeight,
386
+ distance: y < 0 || y >= innerHeight ? 1 + Math.abs(y - innerHeight / 2) : 0,
387
+ sensitive: secret(source),
388
+ input_type: source.type || null,
389
+ frame_origin: e.ownerDocument.location.origin,
390
+ frame_path: framePath(e.ownerDocument),
391
+ form_id: fieldScope(source),
392
+ submit_semantics: submitSemantics(e),
393
+ };
394
+ if (rname === 'link' && e.href) {
395
+ const u = new URL(e.href, location.href);
396
+ base.href = u.origin === location.origin ? u.pathname + u.search : u.host + u.pathname;
397
+ // The page's own word for "the next page of this list", which survives an icon label or another language.
398
+ if (e.rel && e.rel.split(/\s+/).includes('next')) base.next_page = true;
399
+ }
400
+ for (const key of ['checked', 'selected', 'expanded']) {
401
+ const value = e.getAttribute('aria-' + key);
402
+ if (value !== null) base[key] = value === 'true';
403
+ }
404
+ if (['checkbox', 'radio'].includes(source.type)) base.checked = source.checked;
405
+ // A field a form will not submit without: required and still empty, or marked invalid by the page.
406
+ // A custom widget marks the element the user sees, not the input underneath it, so both are asked.
407
+ if (
408
+ e.getAttribute('aria-invalid') === 'true' ||
409
+ source.getAttribute('aria-invalid') === 'true' ||
410
+ (['INPUT', 'SELECT', 'TEXTAREA'].includes(source.tagName) &&
411
+ (source.required || source.getAttribute('aria-required') === 'true') &&
412
+ (source.type === 'radio' && source.required
413
+ ? source.validity.valueMissing
414
+ : !(base.checked ?? (source.value ?? '').trim())))
415
+ ) {
416
+ base.blocking = true;
417
+ }
418
+ if (e.tagName === 'SELECT') {
419
+ base.operations = ['select'];
420
+ base.options = [...e.options].filter(o => !o.disabled && !o.closest('optgroup[disabled]')).map(o => o.label);
421
+ base.value = [...e.selectedOptions].map(o => o.label).join(', ');
422
+ } else if (e.type === 'file') {
423
+ base.operations = ['upload'];
424
+ base.value = null;
425
+ } else {
426
+ const editable =
427
+ !e.readOnly &&
428
+ e.getAttribute('aria-readonly') !== 'true' &&
429
+ (['textbox', 'searchbox', 'spinbutton'].includes(rname) ||
430
+ (rname === 'combobox' && ['INPUT', 'TEXTAREA'].includes(e.tagName)));
431
+ // reveal() is null for <li>, <progress> and <meter>, whose numeric `value`s are not field contents.
432
+ base.value = reveal(source) ?? (e.isContentEditable || rname === 'combobox' ? e.innerText.trim() : null);
433
+ base.operations = editable ? ['fill', 'click', 'enter'] : ['click'];
434
+ // Only an element that names itself draggable is offered as a drag source: `draggable` reports true for
435
+ // every <a> and <img> by default, which would offer the whole page as draggable.
436
+ if (e.getAttribute('draggable') === 'true') base.operations.push('drag');
437
+ }
438
+ controls.push(base);
439
+ }
440
+
441
+ // Controls that read the same cannot be told apart by their label, and a hint naming the right one
442
+ // ("Add to cart under Sauce Labs Backpack") has nothing to match on. Give each twin the widest ancestor
443
+ // that holds it and none of its twins, named by that ancestor's heading or its own first line of text.
444
+ // Only labels that actually collide pay for it.
445
+ const HEADINGS = 'h1,h2,h3,h4,h5,h6,[role="heading"],legend,caption,th,dt,summary';
446
+ const firstLine = text =>
447
+ (text || '')
448
+ .split('\n')
449
+ .map(s => s.replace(/\s+/g, ' ').trim())
450
+ .find(Boolean) || '';
451
+ // A label tied to no control titles what follows it, as a heading would: a blog post's "Date Picker 3" is one,
452
+ // and the post's first heading ("Data Entry Form") named that picker's Submit when only headings counted.
453
+ // A hidden title names nothing on screen: innerText still returns a `hidden` label's text.
454
+ const titles = scope =>
455
+ [...scope.querySelectorAll(`${HEADINGS},label`)].filter(
456
+ e =>
457
+ (e.localName !== 'label' || !e.control) && e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }),
458
+ );
459
+ // A table's header cell names its own row, and a caption its own table, never what sits outside them: a jQuery UI
460
+ // datepicker's Next button was named "Su", its weekday header, and every month's Next read the same.
461
+ const reaches = (title, element) =>
462
+ !['th', 'caption'].includes(title.localName) || title.parentElement.contains(element);
463
+ // The title nearest before the element names its section; the scope's first title is the fallback, and the
464
+ // only answer when `nearest` is false.
465
+ const sectionOf = (scope, element, label, nearest = true) => {
466
+ let first = '',
467
+ before = '';
468
+ for (const title of titles(scope)) {
469
+ const text = firstLine(title.innerText);
470
+ if (!text || text === label || title.contains(element) || !reaches(title, element)) continue;
471
+ first ||= text;
472
+ if (title.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING) before = text;
473
+ }
474
+ return (nearest && before) || first;
475
+ };
476
+ // Labels more than one control shares, filled in once every control is listed.
477
+ const repeated = new Set();
478
+ const nameOf = (scope, element, label, nearest) => {
479
+ const aria = (scope.getAttribute('aria-label') || '').trim();
480
+ if (aria && aria !== label) return aria;
481
+ const section = sectionOf(scope, element, label, nearest);
482
+ if (section) return section;
483
+ // A line that is itself a repeated control's label ("Prev") reads the same in every repeat, so it names none:
484
+ // past it, a datepicker's first line is the month it shows. The label is cut out as whole words, since a day
485
+ // labelled "2" cut out of "October 2026" left "October 0 6".
486
+ const lines = (scope.innerText || '')
487
+ .split('\n')
488
+ .map(line => line.replace(/\s+/g, ' ').trim())
489
+ .filter(line => line && !repeated.has(line));
490
+ return firstLine(lines.map(line => (label ? ` ${line} `.split(` ${label} `).join(' ') : line)).join('\n'));
491
+ };
492
+ const contextOf = (element, twins, label, nearest = true) => {
493
+ // The widest twin-free ancestor is the card, row or section the twins repeat over. A narrower one names
494
+ // the button's own wrapper, which on a shop is its price rather than the product it belongs to.
495
+ let scope = null;
496
+ for (let e = element.parentElement; e && e !== e.ownerDocument.body; e = e.parentElement) {
497
+ if (twins.some(twin => twin !== element && e.contains(twin))) break;
498
+ scope = e;
499
+ }
500
+ return scope ? excerpt(nameOf(scope, element, label, nearest), CONTROL_CONTEXT_CHARS) : '';
501
+ };
502
+ // Content a stylesheet shows only under the pointer (`.card:hover .caption`) is out of reach of every other
503
+ // operation. An element whose hover rule would reveal something now hidden is offered as a hover target.
504
+ const docs = [];
505
+ const addDoc = doc => {
506
+ if (!doc?.body || docs.includes(doc)) return;
507
+ docs.push(doc);
508
+ for (const frame of doc.querySelectorAll('iframe,frame')) {
509
+ try {
510
+ addDoc(frame.contentDocument);
511
+ } catch {
512
+ /* A cross-origin frame is observed in its own session. */
513
+ }
514
+ }
515
+ };
516
+ addDoc(document);
517
+ // Parsing every stylesheet each step is the slow part on a large site, so a document's hover rules are kept
518
+ // until its rule counts change (a new sheet, or rules a script inserted).
519
+ registry.hoverRules ||= new WeakMap();
520
+ const revealsIn = doc => {
521
+ const sheets = [];
522
+ for (const sheet of doc.styleSheets) {
523
+ try {
524
+ sheets.push(sheet.cssRules);
525
+ } catch {
526
+ /* A cross-origin stylesheet cannot be read. */
527
+ }
528
+ }
529
+ const version = sheets.map(rules => rules.length).join(',');
530
+ const cached = registry.hoverRules.get(doc);
531
+ if (cached?.version === version) return cached.reveals;
532
+ const reveals = new Map();
533
+ const collect = rules => {
534
+ for (const rule of rules) {
535
+ if (rule.cssRules?.length) collect(rule.cssRules);
536
+ for (const selector of (rule.selectorText || '').split(',')) {
537
+ // Split `a:hover b` into the hovered compound and what it reveals inside it. Rules that restyle the
538
+ // hovered element itself, or reveal a sibling or a pseudo-element, have nothing to offer here.
539
+ const m = selector.match(/^(.*?):hover([^\s>+~]*)\s*(>?)\s*([^+~]*)$/);
540
+ if (!m || !m[1].trim() || !m[4].trim() || m[4].includes('::')) continue;
541
+ const pair = [
542
+ (m[1] + m[2]).replaceAll(':hover', '').trim(),
543
+ `:scope ${m[3]} ${m[4].replaceAll(':hover', '')}`,
544
+ ];
545
+ reveals.set(pair.join('\n'), pair);
546
+ }
547
+ }
548
+ };
549
+ for (const rules of sheets) collect(rules);
550
+ const found = [...reveals.values()].slice(0, MAX_HOVER_RULES);
551
+ registry.hoverRules.set(doc, { version, reveals: found });
552
+ return found;
553
+ };
554
+ const offered = new Set(controls.map(c => registry.nodes.get(c.id)));
555
+ // Bounded work: each candidate costs a query, and a stylesheet can name thousands of hoverable elements.
556
+ let hovers = 0,
557
+ checked = 0;
558
+ for (const doc of docs) {
559
+ for (const [base, tail] of revealsIn(doc)) {
560
+ let hosts = [];
561
+ try {
562
+ hosts = doc.querySelectorAll(base);
563
+ } catch {
564
+ continue;
565
+ }
566
+ for (const e of hosts) {
567
+ if (hovers >= MAX_HOVER_TARGETS || checked >= MAX_HOVER_CHECKS) break;
568
+ // A link or button's own hover rule restyles it or shows decoration; offering HOVER there as well as
569
+ // CLICK only gives the choice another way to be wrong.
570
+ if (offered.has(e) || e === doc.body || e === doc.documentElement || !visible(e)) continue;
571
+ const r = e.getBoundingClientRect(),
572
+ x = r.x + r.width / 2,
573
+ y = r.y + r.height / 2;
574
+ if (r.width <= 0 || r.height <= 0 || x < 0 || x >= innerWidth || r.height > innerHeight) continue;
575
+ checked++;
576
+ let hidden = false;
577
+ try {
578
+ hidden = [...e.querySelectorAll(tail)].some(
579
+ t => !visible(t) && (t.textContent.trim() || t.querySelector('img,a')),
580
+ );
581
+ } catch {
582
+ continue;
583
+ }
584
+ if (!hidden) continue;
585
+ const image = e.querySelector('img[alt]');
586
+ const label =
587
+ firstLine(e.innerText) ||
588
+ e.getAttribute('aria-label') ||
589
+ image?.getAttribute('alt') ||
590
+ e.getAttribute('title') ||
591
+ e.tagName.toLowerCase();
592
+ const role =
593
+ e.getAttribute('role') ||
594
+ { FIGURE: 'figure', LI: 'listitem', IMG: 'img', TR: 'row', TD: 'cell' }[e.tagName] ||
595
+ 'group';
596
+ const c = {
597
+ id: identity(e),
598
+ role,
599
+ label: excerpt(label, HOVER_LABEL_CHARS),
600
+ offscreen: y < 0 || y >= innerHeight,
601
+ distance: y < 0 || y >= innerHeight ? 1 + Math.abs(y - innerHeight / 2) : 0,
602
+ sensitive: false,
603
+ input_type: null,
604
+ frame_origin: e.ownerDocument.location.origin,
605
+ frame_path: framePath(e.ownerDocument),
606
+ submit_semantics: null,
607
+ value: null,
608
+ operations: ['hover'],
609
+ };
610
+ controls.push(c);
611
+ offered.add(e);
612
+ hovers++;
613
+ }
614
+ }
615
+ }
616
+
617
+ const byLabel = new Map();
618
+ for (const c of controls) {
619
+ const key = JSON.stringify([c.role, c.label]);
620
+ if (!byLabel.has(key)) byLabel.set(key, []);
621
+ byLabel.get(key).push(c);
622
+ }
623
+ for (const group of byLabel.values()) if (group.length > 1) repeated.add(group[0].label);
624
+ for (const group of byLabel.values()) {
625
+ if (group.length < 2) continue;
626
+ const twins = group.map(c => registry.nodes.get(c.id));
627
+ const nearest = group.map(c => contextOf(registry.nodes.get(c.id), twins, c.label));
628
+ group.forEach((c, i) => {
629
+ // Cards that each end in an "Options" heading share their nearest title, which would name every twin
630
+ // alike; a twin whose nearest title another twin shares is named by its card's first title instead.
631
+ const shared = nearest.indexOf(nearest[i]) !== nearest.lastIndexOf(nearest[i]);
632
+ const context = shared ? contextOf(registry.nodes.get(c.id), twins, c.label, false) : nearest[i];
633
+ if (context) c.context = context;
634
+ });
635
+ // Options that are direct children of one list share every ancestor, and their aria-label hides the text
636
+ // inside them, so "Springfield" twice read alike even when one says "City in Illinois" and the other
637
+ // "State park in Oregon". A twin with no context is told apart by what it shows besides its label, when
638
+ // that differs.
639
+ // Inline children join into one run in innerText, so each text node is read as a line of its own.
640
+ const ownOf = (element, label) => {
641
+ const lines = [];
642
+ const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
643
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
644
+ const line = node.data.replace(/\s+/g, ' ').trim();
645
+ // A caption a hover rule reveals is not shown, so it cannot tell twins apart.
646
+ if (!line || line === label || repeated.has(line)) continue;
647
+ if (!node.parentElement?.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) continue;
648
+ lines.push(` ${line} `.split(` ${label} `).join(' '));
649
+ }
650
+ return excerpt(firstLine(lines.join('\n')), CONTROL_CONTEXT_CHARS);
651
+ };
652
+ const own = group.map(c => (c.context ? '' : ownOf(registry.nodes.get(c.id), c.label)));
653
+ if (new Set(own.filter(Boolean)).size > 1 || (own.some(Boolean) && group.some(c => c.context))) {
654
+ group.forEach((c, i) => {
655
+ if (own[i]) c.context = own[i];
656
+ });
657
+ }
658
+ // Twins whose surroundings name none of them, like a row of identical avatars, still differ by position.
659
+ if (group.every(c => !c.context)) {
660
+ twins.sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
661
+ for (const c of group) {
662
+ c.context = `${twins.indexOf(registry.nodes.get(c.id)) + 1} of ${twins.length}`;
663
+ }
664
+ }
665
+ }
666
+
667
+ // A date field's own label ("Start Date") rarely says which picker it belongs to when a page offers several
668
+ // ("Date Picker 3"): unlike a colliding label, a uniquely named one never reaches the loop above, so the
669
+ // nearest ancestor that names a section is attached here on its own, without the twin-avoidance `contextOf`
670
+ // needs to keep a card from naming its neighbour.
671
+ const nearestHeading = (element, label) => {
672
+ for (let e = element.parentElement; e && e !== e.ownerDocument.body; e = e.parentElement) {
673
+ const aria = (e.getAttribute('aria-label') || '').trim();
674
+ if (aria && aria !== label) return excerpt(aria, CONTROL_CONTEXT_CHARS);
675
+ const section = sectionOf(e, element, label);
676
+ if (section) return excerpt(section, CONTROL_CONTEXT_CHARS);
677
+ }
678
+ return '';
679
+ };
680
+ for (const c of controls) {
681
+ if (!c.context && DATE_TYPES.includes(c.input_type)) {
682
+ const context = nearestHeading(registry.nodes.get(c.id), c.label);
683
+ if (context) c.context = context;
684
+ }
685
+ }
686
+
687
+ // Flattened text loses empty cells: a calendar day then has no weekday, and the chooser picked a Tuesday
688
+ // for the first Friday. Keep the table's column names beside its controls, including uniquely named ones.
689
+ const columns = new Map();
690
+ for (const c of controls) {
691
+ const cell = registry.nodes.get(c.id).closest('td,th');
692
+ const table = cell?.closest('table');
693
+ if (!table) continue;
694
+ if (!columns.has(table)) {
695
+ const positions = new Map(),
696
+ headers = [],
697
+ occupied = [];
698
+ const rows = [...table.rows];
699
+ rows.forEach((row, r) => {
700
+ let col = 0;
701
+ for (const cell of row.cells) {
702
+ while (occupied[col] > r) col++;
703
+ const end = col + cell.colSpan;
704
+ positions.set(cell, [col, end, r]);
705
+ const remaining = row.parentElement.rows.length - row.sectionRowIndex;
706
+ for (let i = col; i < end; i++) occupied[i] = r + Math.min(cell.rowSpan || remaining, remaining);
707
+ if (
708
+ cell.tagName === 'TH' &&
709
+ !['row', 'rowgroup'].includes(cell.scope) &&
710
+ (cell.scope ||
711
+ row.parentElement.tagName === 'THEAD' ||
712
+ (r === 0 && [...row.cells].every(c => c.tagName === 'TH')))
713
+ )
714
+ headers.push(cell);
715
+ col = end;
716
+ }
717
+ });
718
+ const names = new Map();
719
+ for (const [cell, [start, end, row]] of positions) {
720
+ const associated = cell.headers
721
+ ? cell.headers.split(/\s+/).map(id => cell.ownerDocument.getElementById(id))
722
+ : headers.filter(header => {
723
+ const [left, right, above] = positions.get(header);
724
+ return above < row && left < end && right > start;
725
+ });
726
+ const labels = associated
727
+ .filter(header => header && visible(header))
728
+ .map(
729
+ header =>
730
+ header.getAttribute('abbr') ||
731
+ header.getAttribute('title') ||
732
+ header.querySelector('[title]')?.getAttribute('title') ||
733
+ labelOf(header),
734
+ );
735
+ names.set(cell, [...new Set(labels.filter(Boolean))].join(' / '));
736
+ }
737
+ columns.set(table, names);
738
+ }
739
+ const column = columns.get(table).get(cell);
740
+ if (column) c.context = [c.context, `column: ${excerpt(column, CONTROL_CONTEXT_CHARS)}`].filter(Boolean).join('; ');
741
+ }
742
+
743
+ // Python applies the configured caps after merging frames; keep the nearest controls first.
744
+ controls.sort((a, b) => a.distance - b.distance);
745
+
746
+ // Text in a same-process child document is on screen as much as the parent's: a frameset page has no text of
747
+ // its own at all. Each child is read in its own coordinates, shifted by where its frame sits.
748
+ const words = [];
749
+ const readText = (doc, dx, dy) => {
750
+ if (!doc.body) return;
751
+ const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
752
+ const range = doc.createRange();
753
+ let node;
754
+ while ((node = walker.nextNode())) {
755
+ const value = node.textContent.trim(),
756
+ parent = node.parentElement;
757
+ if (!value || !parent || parent.closest('script,style,noscript,template') || !visible(parent)) continue;
758
+ range.selectNodeContents(node);
759
+ const r = range.getBoundingClientRect();
760
+ const top = r.top + dy,
761
+ left = r.left + dx;
762
+ if (
763
+ r.width > 0 &&
764
+ r.height > 0 &&
765
+ top + r.height > 0 &&
766
+ top < innerHeight &&
767
+ left + r.width > 0 &&
768
+ left < innerWidth
769
+ ) {
770
+ words.push(value);
771
+ }
772
+ }
773
+ for (const frame of doc.querySelectorAll('iframe,frame')) {
774
+ let inner = null;
775
+ try {
776
+ inner = frame.contentDocument;
777
+ } catch {
778
+ inner = null;
779
+ }
780
+ if (!inner) continue;
781
+ const f = frame.getBoundingClientRect();
782
+ readText(inner, dx + f.left + frame.clientLeft, dy + f.top + frame.clientTop);
783
+ }
784
+ };
785
+ readText(document, 0, 0);
786
+ const viewport_text = words.join('\n');
787
+
788
+ const guards = {};
789
+ for (const c of controls) guards[c.id] = registry.guard(registry.nodes.get(c.id));
790
+ // A dependent field can appear without changing page text. Compare all controls,
791
+ // excusing only the value and required marker of the field just filled.
792
+ const formState = controls.map(({ distance, offscreen, ...c }) => {
793
+ const form = registry.nodes.get(c.id)?.form;
794
+ return { ...c, form: form ? [form.action, form.method, form.target] : null };
795
+ });
796
+ const formPage = JSON.stringify([location.href, performance.timeOrigin, document.title, viewport_text]);
797
+ if (typeof mode === 'object') {
798
+ const previous = registry.formObserved;
799
+ if (!previous || previous.page !== formPage) return false;
800
+ const expected = previous.controls.map(c => {
801
+ if (c.id !== mode.id) return c;
802
+ const { blocking, ...filled } = c;
803
+ return mode.text ? { ...filled, value: mode.text } : c;
804
+ });
805
+ const same = JSON.stringify(expected) === JSON.stringify(formState);
806
+ if (same) registry.formObserved = { page: formPage, controls: formState };
807
+ return same;
808
+ }
809
+ registry.formObserved = { page: formPage, controls: formState };
810
+ // Kept so a later check can ask whether any of these controls changed without sending the guards back.
811
+ registry.observed = new Map(controls.map(c => [c.id, JSON.stringify(guards[c.id])]));
812
+ const field_state = [...document.querySelectorAll('input,textarea,select')]
813
+ .filter(safe)
814
+ .map(e => [identity(e), reveal(e), e.checked, e.selectedIndex, e.disabled, e.readOnly]);
815
+ // Compare meaning and identity for the page_key fingerprint; geometry is re-resolved just before input.
816
+ const semantics = controls.map(({ distance, ...c }) => c);
817
+ const page_key = JSON.stringify([
818
+ location.href,
819
+ scrollX,
820
+ scrollY,
821
+ innerWidth,
822
+ innerHeight,
823
+ document.title,
824
+ viewport_text,
825
+ semantics,
826
+ field_state,
827
+ ]);
828
+
829
+ return {
830
+ url: location.href,
831
+ title: document.title,
832
+ viewport_text,
833
+ document_key: String(performance.timeOrigin),
834
+ controls: semantics,
835
+ page_key,
836
+ guards,
837
+ inaccessible_frames: inaccessible,
838
+ };
839
+ })