@xingular/harness 1.1.1 → 1.2.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 (223) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +28 -25
  3. package/dist/cli.js +6675 -2501
  4. package/dist/sdk/accounts/accounts.d.ts +27 -3
  5. package/dist/sdk/accounts/accounts.js +78 -24
  6. package/dist/sdk/accounts/secrets.d.ts +12 -0
  7. package/dist/sdk/accounts/secrets.js +114 -7
  8. package/dist/sdk/accounts/store.d.ts +26 -1
  9. package/dist/sdk/accounts/store.js +1 -1
  10. package/dist/sdk/agents/builtin.js +15 -1
  11. package/dist/sdk/agents/planResearch.d.ts +5 -0
  12. package/dist/sdk/agents/planResearch.js +19 -0
  13. package/dist/sdk/agents/profiles.d.ts +2 -1
  14. package/dist/sdk/agents/profiles.js +9 -4
  15. package/dist/sdk/annotations/pageScript.js +349 -48
  16. package/dist/sdk/annotations/part.js +34 -23
  17. package/dist/sdk/annotations/store.d.ts +7 -0
  18. package/dist/sdk/annotations/store.js +13 -5
  19. package/dist/sdk/browser/backend.d.ts +16 -0
  20. package/dist/sdk/browser/backend.js +17 -0
  21. package/dist/sdk/browser/contracts.d.ts +19 -0
  22. package/dist/sdk/browser/contracts.js +116 -0
  23. package/dist/sdk/browser/tools.d.ts +1 -4
  24. package/dist/sdk/browser/tools.js +14 -91
  25. package/dist/sdk/cli.js +5 -4
  26. package/dist/sdk/commands/list.d.ts +3 -0
  27. package/dist/sdk/commands/list.js +75 -0
  28. package/dist/sdk/commands/registry.d.ts +250 -0
  29. package/dist/sdk/commands/registry.js +47 -0
  30. package/dist/sdk/commands/routes.d.ts +16 -0
  31. package/dist/sdk/commands/routes.js +33 -0
  32. package/dist/sdk/core/commandBudget.d.ts +23 -0
  33. package/dist/sdk/core/commandBudget.js +57 -0
  34. package/dist/sdk/core/index.js +17 -1
  35. package/dist/sdk/core/interactiveProfile.d.ts +46 -0
  36. package/dist/sdk/core/interactiveProfile.js +143 -0
  37. package/dist/sdk/core/workspace.d.ts +13 -0
  38. package/dist/sdk/core/workspace.js +88 -0
  39. package/dist/sdk/core/workspaceBashTool.d.ts +4 -0
  40. package/dist/sdk/core/workspaceBashTool.js +36 -0
  41. package/dist/sdk/core/workspaceSandbox.d.ts +31 -0
  42. package/dist/sdk/core/workspaceSandbox.js +320 -0
  43. package/dist/sdk/core/workspaceWriteOperations.d.ts +22 -0
  44. package/dist/sdk/core/workspaceWriteOperations.js +206 -0
  45. package/dist/sdk/doctor/types.d.ts +2 -2
  46. package/dist/sdk/harness.d.ts +7 -0
  47. package/dist/sdk/harness.js +42 -2
  48. package/dist/sdk/index.d.ts +6 -1
  49. package/dist/sdk/index.js +3 -0
  50. package/dist/sdk/pi/runtime.d.ts +2 -1
  51. package/dist/sdk/pi/runtime.js +2 -2
  52. package/dist/sdk/plugins/builtin.js +29 -12
  53. package/dist/sdk/plugins/builtinRegistry.d.ts +10 -3
  54. package/dist/sdk/plugins/builtinRegistry.js +30 -8
  55. package/dist/sdk/plugins/catalogue.js +2 -1
  56. package/dist/sdk/plugins/commandPrefix.d.ts +8 -0
  57. package/dist/sdk/plugins/commandPrefix.js +21 -0
  58. package/dist/sdk/plugins/firstParty.d.ts +1 -1
  59. package/dist/sdk/plugins/firstParty.js +8 -2
  60. package/dist/sdk/plugins/inventory.js +4 -3
  61. package/dist/sdk/plugins/manifest.d.ts +2 -0
  62. package/dist/sdk/plugins/manifest.js +2 -1
  63. package/dist/sdk/plugins/surfaces.d.ts +5 -0
  64. package/dist/sdk/plugins/surfaces.js +11 -0
  65. package/dist/sdk/plugins/types.d.ts +15 -0
  66. package/dist/sdk/prompt/compile.d.ts +14 -0
  67. package/dist/sdk/prompt/compile.js +65 -29
  68. package/dist/sdk/prompt/standard.d.ts +17 -0
  69. package/dist/sdk/prompt/standard.js +21 -0
  70. package/dist/sdk/server/methods.js +18 -0
  71. package/dist/sdk/server/remote.js +36 -11
  72. package/dist/sdk/sessions/compactionCheckpoint.d.ts +11 -0
  73. package/dist/sdk/sessions/compactionCheckpoint.js +79 -0
  74. package/dist/sdk/sessions/continuation.d.ts +3 -0
  75. package/dist/sdk/sessions/continuation.js +5 -1
  76. package/dist/sdk/sessions/contracts.d.ts +168 -0
  77. package/dist/sdk/sessions/contracts.js +59 -1
  78. package/dist/sdk/sessions/control.d.ts +72 -0
  79. package/dist/sdk/sessions/control.js +158 -0
  80. package/dist/sdk/sessions/initialBash.d.ts +7 -0
  81. package/dist/sdk/sessions/initialBash.js +7 -0
  82. package/dist/sdk/sessions/planMode.d.ts +14 -3
  83. package/dist/sdk/sessions/planMode.js +73 -16
  84. package/dist/sdk/sessions/registry.d.ts +1 -1
  85. package/dist/sdk/sessions/registry.js +2 -2
  86. package/dist/sdk/sessions/runStore.d.ts +14 -0
  87. package/dist/sdk/sessions/runStore.js +35 -0
  88. package/dist/sdk/sessions/runner.d.ts +40 -2
  89. package/dist/sdk/sessions/runner.js +161 -13
  90. package/dist/sdk/sessions/sessions.d.ts +23 -3
  91. package/dist/sdk/sessions/sessions.js +202 -17
  92. package/dist/sdk/sessions/subagentService.d.ts +9 -0
  93. package/dist/sdk/sessions/subagentService.js +63 -4
  94. package/dist/sdk/sessions/workSlice.d.ts +103 -0
  95. package/dist/sdk/sessions/workSlice.js +297 -0
  96. package/dist/sdk/sessions/workspaceBoundary.d.ts +15 -0
  97. package/dist/sdk/sessions/workspaceBoundary.js +46 -0
  98. package/dist/sdk/terminal/browserControl.js +2 -1
  99. package/dist/sdk/terminal/controller.d.ts +11 -3
  100. package/dist/sdk/terminal/controller.js +40 -13
  101. package/dist/sdk/terminal/opentui/theme.d.ts +43 -0
  102. package/dist/sdk/terminal/opentui/theme.js +87 -0
  103. package/dist/sdk/terminal/piExtension.d.ts +6 -1
  104. package/dist/sdk/terminal/piExtension.js +154 -54
  105. package/dist/sdk/terminal/standalone.d.ts +7 -0
  106. package/dist/sdk/terminal/standalone.js +72 -13
  107. package/dist/sdk/terminal/subagentMonitor.d.ts +11 -2
  108. package/dist/sdk/terminal/subagentMonitor.js +32 -1
  109. package/dist/sdk/terminal/terminals.d.ts +19 -2
  110. package/dist/sdk/terminal/terminals.js +245 -35
  111. package/dist/sdk/terminal/tui/annotateCommand.d.ts +1 -1
  112. package/dist/sdk/terminal/tui/annotateCommand.js +3 -3
  113. package/dist/sdk/terminal/tui/commandPages.d.ts +3 -1
  114. package/dist/sdk/terminal/tui/commandPages.js +3 -0
  115. package/dist/sdk/terminal/tui/commands.d.ts +2 -0
  116. package/dist/sdk/terminal/tui/commands.js +205 -124
  117. package/dist/sdk/terminal/tui/list.d.ts +14 -1
  118. package/dist/sdk/terminal/tui/list.js +96 -14
  119. package/dist/sdk/terminal/tui/pageOverlay.js +13 -4
  120. package/dist/sdk/terminal/tui/pages/annotatePage.d.ts +1 -1
  121. package/dist/sdk/terminal/tui/pages/notesPage.d.ts +5 -5
  122. package/dist/sdk/terminal/tui/pages/notesPage.js +18 -18
  123. package/dist/sdk/terminal/tui/pages/pluginsPage.d.ts +0 -5
  124. package/dist/sdk/terminal/tui/pages/pluginsPage.js +9 -3
  125. package/dist/sdk/terminal/tui/pages/subagentsPage.d.ts +1 -0
  126. package/dist/sdk/terminal/tui/pages/subagentsPage.js +98 -16
  127. package/dist/sdk/terminal/tui/pages/usagePage.d.ts +43 -0
  128. package/dist/sdk/terminal/tui/pages/usagePage.js +370 -0
  129. package/dist/sdk/terminal/tui/piAdapter.d.ts +1 -2
  130. package/dist/sdk/terminal/tui/piAdapter.js +3 -5
  131. package/dist/sdk/terminal/tui/pluginCommands.d.ts +2 -0
  132. package/dist/sdk/terminal/tui/pluginCommands.js +13 -9
  133. package/dist/sdk/terminal/tui/runtime.js +23 -3
  134. package/dist/sdk/terminal/tui/sessionPicker.d.ts +40 -0
  135. package/dist/sdk/terminal/tui/sessionPicker.js +141 -0
  136. package/dist/sdk/tools/checklist.js +1 -0
  137. package/dist/sdk/tools/checkpoint.d.ts +27 -0
  138. package/dist/sdk/tools/checkpoint.js +154 -0
  139. package/dist/sdk/tools/getCapabilities.d.ts +31 -0
  140. package/dist/sdk/tools/getCapabilities.js +34 -0
  141. package/dist/sdk/tools/spawnSubagent.js +4 -3
  142. package/dist/sdk/usage/analytics.d.ts +20 -0
  143. package/dist/sdk/usage/analytics.js +151 -0
  144. package/dist/sdk/usage/ledger.d.ts +38 -0
  145. package/dist/sdk/usage/ledger.js +142 -0
  146. package/dist/sdk/usage/limits/adapters.d.ts +11 -0
  147. package/dist/sdk/usage/limits/adapters.js +349 -0
  148. package/dist/sdk/usage/limits/capture.d.ts +8 -0
  149. package/dist/sdk/usage/limits/capture.js +47 -0
  150. package/dist/sdk/usage/limits/http.d.ts +12 -0
  151. package/dist/sdk/usage/limits/http.js +51 -0
  152. package/dist/sdk/usage/limits/normalize.d.ts +15 -0
  153. package/dist/sdk/usage/limits/normalize.js +211 -0
  154. package/dist/sdk/usage/limits/service.d.ts +30 -0
  155. package/dist/sdk/usage/limits/service.js +176 -0
  156. package/dist/sdk/usage/limits/types.d.ts +65 -0
  157. package/dist/sdk/usage/limits/types.js +1 -0
  158. package/dist/sdk/usage/serverMethods.d.ts +3 -0
  159. package/dist/sdk/usage/serverMethods.js +22 -0
  160. package/dist/sdk/usage/service.d.ts +32 -0
  161. package/dist/sdk/usage/service.js +66 -0
  162. package/dist/sdk/usage/settings.d.ts +7 -0
  163. package/dist/sdk/usage/settings.js +28 -0
  164. package/dist/sdk/usage/types.d.ts +60 -0
  165. package/dist/sdk/usage/types.js +4 -0
  166. package/dist/terminal-opentui.js +5207 -1883
  167. package/dist/terminal-pi.js +1658 -271
  168. package/docs/architecture/02-sessions-and-runs.md +128 -26
  169. package/docs/architecture/04-prompt.md +50 -12
  170. package/docs/architecture/06-plugins-and-capabilities.md +24 -7
  171. package/docs/architecture/07-tools-and-gate.md +104 -24
  172. package/docs/architecture/08-memory-and-history.md +4 -2
  173. package/docs/architecture/09-accounts-and-connectors.md +31 -1
  174. package/docs/architecture/10-server-protocol.md +58 -3
  175. package/docs/architecture/11-terminal.md +105 -42
  176. package/docs/architecture/12-storage.md +30 -2
  177. package/docs/architecture/13-world-tools.md +22 -1
  178. package/docs/architecture/14-surfaces-and-parity.md +54 -7
  179. package/docs/architecture/README.md +3 -1
  180. package/docs/architecture/proposals/PROP-0017-page-annotations.md +6 -2
  181. package/docs/architecture/proposals/PROP-0018-annotation-queue-and-app-targets.md +1 -1
  182. package/docs/architecture/proposals/PROP-0031-plugin-command-owner-namespaces.md +54 -0
  183. package/docs/architecture/proposals/PROP-0032-planning-quality.md +196 -0
  184. package/docs/architecture/proposals/PROP-0033-compaction-plan-checkpoints.md +75 -0
  185. package/docs/architecture/proposals/PROP-0034-conversation-fast-mode.md +120 -0
  186. package/docs/architecture/proposals/PROP-0035-retain-subagent-status.md +47 -0
  187. package/docs/architecture/proposals/PROP-0036-first-class-harness-commands.md +59 -0
  188. package/docs/architecture/proposals/PROP-0037-workspace-write-boundary.md +63 -0
  189. package/docs/architecture/proposals/PROP-0038-annotate-open-shadow-roots.md +73 -0
  190. package/docs/architecture/proposals/PROP-0039-workspace-boundary-hardening.md +139 -0
  191. package/docs/architecture/proposals/PROP-0040-one-launch-path.md +87 -0
  192. package/docs/architecture/proposals/PROP-0041-bounded-work-slices.md +247 -0
  193. package/docs/architecture/proposals/PROP-0042-usage-and-provider-limits.md +127 -0
  194. package/docs/architecture/proposals/PROP-0043-read-only-plan-bash.md +74 -0
  195. package/docs/architecture/proposals/README.md +14 -0
  196. package/docs/decisions/ADR-0001-owned-terminal.md +1 -1
  197. package/docs/decisions/ADR-0021-required-command-namespaces.md +1 -1
  198. package/docs/decisions/ADR-0031-plugin-command-owner-namespaces.md +37 -0
  199. package/docs/decisions/ADR-0032-compaction-run-checkpoints.md +33 -0
  200. package/docs/decisions/ADR-0033-plan-mode-research-delegation.md +47 -0
  201. package/docs/decisions/ADR-0034-first-class-harness-terminal-commands.md +25 -0
  202. package/docs/decisions/ADR-0035-one-launch-path.md +40 -0
  203. package/docs/decisions/ADR-0036-workspace-write-operation-boundary.md +54 -0
  204. package/docs/decisions/ADR-0037-usage-ledger-and-provider-limits.md +44 -0
  205. package/docs/decisions/ADR-0038-plan-bash-os-read-only.md +29 -0
  206. package/docs/experiments/EXP-0003-performance-audit/data/session-runtime-replication.json +420 -0
  207. package/docs/experiments/EXP-0003-performance-audit/data/session-runtime.json +420 -0
  208. package/docs/experiments/EXP-0003-performance-audit/data/terminal-compact.json +376 -0
  209. package/docs/experiments/EXP-0003-performance-audit/data/terminal-history.json +422 -0
  210. package/docs/experiments/EXP-0003-performance-audit/data/terminal-question.json +95 -0
  211. package/docs/experiments/EXP-0003-performance-audit/data/terminal-wide.json +1036 -0
  212. package/docs/experiments/EXP-0003-performance-audit/plan.md +109 -0
  213. package/docs/experiments/EXP-0003-performance-audit/results.md +151 -0
  214. package/docs/handoff-2026-10-25-app-parity-and-solo-tabs.md +202 -0
  215. package/docs/parity.md +80 -24
  216. package/docs/releases/1.1.1.md +28 -0
  217. package/docs/releases/1.2.0.md +49 -0
  218. package/docs/releases/4.0.0.md +10 -9
  219. package/docs/releases/README.md +35 -30
  220. package/docs/study/00-index.md +3 -0
  221. package/docs/study/STUDY-0003-long-horizon-runs.md +210 -0
  222. package/docs/terminal-tui.md +100 -59
  223. package/package.json +4 -3
@@ -49,6 +49,189 @@ export const PAGE_SCRIPT = String.raw `(() => {
49
49
  }
50
50
  return document.body
51
51
  }
52
+ // ---- The composed tree: open shadow roots and slotted content, in rendered order. Targets
53
+ // captured with it carry tree:'composed'; older targets keep the document-only rules above
54
+ // and below, so a saved note never changes what it points at. Closed shadow roots stay
55
+ // invisible to script, and frames are never entered.
56
+ const refusedContent = 'input,textarea,select,option,iframe,object,embed,[contenteditable],[role="textbox"],[data-sensitive],[data-private],[autocomplete*="password"],[autocomplete*="cc-"],[autocomplete*="one-time-code"]'
57
+ const tooLarge = () => new Error('This page is too large to anchor safely. Use a page note instead.')
58
+ const composedParent = node => node.assignedSlot ?? node.parentElement ?? (node.parentNode instanceof ShadowRoot ? node.parentNode.host : null)
59
+ const flatChildren = node => {
60
+ if (node instanceof Element && node.shadowRoot) return Array.from(node.shadowRoot.childNodes)
61
+ if (node instanceof HTMLSlotElement) {
62
+ const assigned = node.assignedNodes()
63
+ return assigned.length ? assigned : Array.from(node.childNodes)
64
+ }
65
+ return Array.from(node.childNodes)
66
+ }
67
+ const shown = element => {
68
+ if (overlays.has(element) || element.matches(excluded)) return false
69
+ const style = getComputedStyle(element)
70
+ if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || Number(style.opacity) === 0) return false
71
+ return style.display === 'contents' || element.getClientRects().length > 0
72
+ }
73
+ const allowedComposed = element => {
74
+ if (!(element instanceof Element) || !element.isConnected || !element.getClientRects().length) return false
75
+ for (let current = element; current; current = composedParent(current)) if (!shown(current)) return false
76
+ return true
77
+ }
78
+ const rootsOf = node => {
79
+ const roots = []
80
+ for (let root = node?.getRootNode?.(); root instanceof ShadowRoot; root = root.host.getRootNode()) roots.push(root)
81
+ return roots
82
+ }
83
+ const openRoots = () => {
84
+ const roots = []
85
+ const pending = [document]
86
+ let seen = 0
87
+ while (pending.length) {
88
+ for (const element of pending.pop().querySelectorAll('*')) {
89
+ if (++seen > 50000) throw tooLarge()
90
+ if (element.shadowRoot) { roots.push(element.shadowRoot); pending.push(element.shadowRoot) }
91
+ }
92
+ }
93
+ return roots
94
+ }
95
+ const byTagComposed = tag => {
96
+ const found = []
97
+ const pending = [document]
98
+ let seen = 0
99
+ while (pending.length) {
100
+ for (const element of pending.pop().querySelectorAll('*')) {
101
+ if (++seen > 50000) throw tooLarge()
102
+ if (element.localName === tag) found.push(element)
103
+ if (element.shadowRoot) pending.push(element.shadowRoot)
104
+ }
105
+ }
106
+ return found
107
+ }
108
+ const blockComposed = node => {
109
+ let element = composedParent(node)
110
+ while (element && element !== document.body) {
111
+ if (!['inline', 'contents', 'inline-block'].includes(getComputedStyle(element).display)) return element
112
+ element = composedParent(element)
113
+ }
114
+ return document.body
115
+ }
116
+ // The same normalized stream as below, walked over the composed tree. It also records where
117
+ // it passed visible fields or private content, so a selection across one is refused, and
118
+ // where each node starts and ends, so selection boundaries in any tree map to positions.
119
+ const streamComposed = (root = document.body) => {
120
+ const chars = []
121
+ const map = []
122
+ const refused = []
123
+ const spans = new Map()
124
+ if (!root) return { chars, map, refused, spans }
125
+ let texts = 0
126
+ let elements = 0
127
+ let previousBlock
128
+ const append = (char, start, end) => {
129
+ if (/\s/u.test(char)) {
130
+ if (!chars.length) return false
131
+ if (chars.at(-1) === ' ') { map.at(-1).end = end; return false }
132
+ char = ' '
133
+ }
134
+ chars.push(char)
135
+ map.push({ start, end })
136
+ return true
137
+ }
138
+ const closing = Symbol('end of node')
139
+ const stack = [root]
140
+ while (stack.length) {
141
+ const node = stack.pop()
142
+ if (node[closing]) { node[closing].end = chars.length; continue }
143
+ if (node.nodeType === Node.TEXT_NODE) {
144
+ if (++texts > 20000 || chars.length + node.data.length > 250000) throw tooLarge()
145
+ // A component with a closed shadow root shows only the light text it slots; the rest is not rendered.
146
+ const holder = node.parentElement
147
+ if (holder && holder.localName.includes('-') && !holder.shadowRoot) {
148
+ const probe = document.createRange()
149
+ probe.selectNodeContents(node)
150
+ if (!probe.getClientRects().length) continue
151
+ }
152
+ const currentBlock = blockComposed(node)
153
+ if (previousBlock && currentBlock !== previousBlock && chars.length) append(' ', map.at(-1).end, [node, 0])
154
+ previousBlock = currentBlock
155
+ const span = { at: chars.length, starts: [], end: chars.length }
156
+ let offset = 0
157
+ for (const char of node.data) {
158
+ if (append(char, [node, offset], [node, offset + char.length])) span.starts.push(offset)
159
+ offset += char.length
160
+ }
161
+ span.end = chars.length
162
+ spans.set(node, span)
163
+ continue
164
+ }
165
+ if (node.nodeType !== Node.ELEMENT_NODE) continue
166
+ if (++elements > 50000) throw tooLarge()
167
+ if (node !== root && !shown(node)) {
168
+ if (node.matches(refusedContent) && node.getClientRects().length > 0) refused.push(chars.length)
169
+ continue
170
+ }
171
+ const span = { at: chars.length, starts: [], end: chars.length }
172
+ spans.set(node, span)
173
+ stack.push({ [closing]: span })
174
+ const children = flatChildren(node)
175
+ for (let index = children.length - 1; index >= 0; index--) stack.push(children[index])
176
+ }
177
+ if (chars.at(-1) === ' ') { chars.pop(); map.pop() }
178
+ return { chars, map, refused, spans }
179
+ }
180
+ // A boundary point (in any tree) as a stream position: the first character at or after it.
181
+ const positionAt = (data, container, offset) => {
182
+ if (container.nodeType === Node.TEXT_NODE) {
183
+ const span = data.spans.get(container)
184
+ return span ? span.at + span.starts.filter(start => start < offset).length : null
185
+ }
186
+ const children = container.childNodes
187
+ for (let index = offset; index < children.length; index++) {
188
+ const span = data.spans.get(children[index])
189
+ if (span) return span.at
190
+ }
191
+ const own = data.spans.get(container instanceof ShadowRoot ? container.host : container)
192
+ return own ? own.end : null
193
+ }
194
+ const textComposed = element => streamComposed(element).chars.join('')
195
+ // One Range per text node: a Range cannot span shadow trees.
196
+ const segmentsFor = (data, start, end) => {
197
+ const segments = []
198
+ let run = null
199
+ for (let index = start; index < end; index++) {
200
+ const entry = data.map[index]
201
+ if (entry.start[0] !== entry.end[0]) { run = null; continue }
202
+ if (run && run.node === entry.start[0]) { run.range.setEnd(...entry.end); continue }
203
+ const range = document.createRange()
204
+ range.setStart(...entry.start)
205
+ range.setEnd(...entry.end)
206
+ run = { node: entry.start[0], range }
207
+ segments.push(range)
208
+ }
209
+ return segments
210
+ }
211
+ const composedSelectionTarget = () => {
212
+ const selection = getSelection()
213
+ // rangeCount, isCollapsed and getRangeAt see a selection in a shadow root only from the document:
214
+ // WebKit reports no range at all for one that crosses into a component.
215
+ if (!selection || typeof selection.getComposedRanges !== 'function') return null
216
+ const roots = openRoots()
217
+ let ranges
218
+ try { ranges = selection.getComposedRanges({ shadowRoots: roots }) } catch { ranges = selection.getComposedRanges(...roots) }
219
+ const range = ranges?.[0]
220
+ if (!range || range.collapsed) return null
221
+ const data = streamComposed()
222
+ let start = positionAt(data, range.startContainer, range.startOffset)
223
+ let end = positionAt(data, range.endContainer, range.endOffset)
224
+ if (start === null || end === null) return null
225
+ while (data.chars[start] === ' ') start++
226
+ while (end > start && data.chars[end - 1] === ' ') end--
227
+ if (end <= start || end - start > 4000) return null
228
+ // Refuse ranges crossing fields or private content rather than silently capturing partial secrets.
229
+ if (data.refused.some(position => position > start && position < end)) return null
230
+ return {
231
+ target: { kind: 'text', exact: data.chars.slice(start, end).join(''), prefix: data.chars.slice(Math.max(0, start - 128), start).join(''), suffix: data.chars.slice(end, end + 128).join(''), start, end, tree: 'composed' },
232
+ segments: segmentsFor(data, start, end)
233
+ }
234
+ }
52
235
  // Positions are normalized Unicode code points. Mapping offsets remain DOM UTF-16.
53
236
  const stream = (root = document.body) => {
54
237
  const chars = []
@@ -86,22 +269,58 @@ export const PAGE_SCRIPT = String.raw `(() => {
86
269
  }
87
270
  const text = element => stream(element).chars.join('')
88
271
  const role = element => norm(element.getAttribute('role')) || ({ BUTTON: 'button', A: element.hasAttribute('href') ? 'link' : '', IMG: 'img', SUMMARY: 'button' }[element.tagName] ?? '')
89
- const name = element => {
272
+ // composed: read the composed tree (open shadow roots), where ids are scoped to their root.
273
+ const name = (element, composed = false) => {
274
+ const scope = composed ? element.getRootNode() : document
90
275
  const labelled = norm(element.getAttribute('aria-labelledby')).split(' ').filter(Boolean).map(id => {
91
- const label = document.getElementById(id)
92
- return allowed(label) ? text(label) : ''
276
+ const label = scope.getElementById?.(id) ?? null
277
+ return composed ? (allowedComposed(label) ? textComposed(label) : '') : (allowed(label) ? text(label) : '')
93
278
  }).join(' ')
94
- return bounded(labelled || element.getAttribute('aria-label') || (element.tagName === 'IMG' ? element.getAttribute('alt') : '') || text(element), 200)
279
+ return bounded(labelled || element.getAttribute('aria-label') || (element.tagName === 'IMG' ? element.getAttribute('alt') : '') || (composed ? textComposed(element) : text(element)), 200)
280
+ }
281
+ const specificSelector = (selector, tag) => selector.trim().toLowerCase() !== tag.toLowerCase() && ['#', '.', '[', ':', '>', '+', '~'].some(marker => selector.includes(marker))
282
+ const selectorPart = element => {
283
+ const tag = element.localName
284
+ const classes = Array.from(element.classList).slice(0, 3).map(name => '.' + CSS.escape(name)).join('')
285
+ const testId = element.getAttribute('data-testid')
286
+ const identity = element.id ? '#' + CSS.escape(element.id) : tag + (testId ? '[data-testid="' + CSS.escape(testId) + '"]' : '')
287
+ return identity + classes
288
+ }
289
+ const selectorFor = (element, semantic) => {
290
+ const root = element.getRootNode()
291
+ const unique = selector => {
292
+ try {
293
+ const matches = root.querySelectorAll(selector)
294
+ return matches.length === 1 && matches[0] === element
295
+ } catch { return false }
296
+ }
297
+ const direct = selectorPart(element)
298
+ if (cp(direct).length <= 500 && unique(direct) && (semantic || specificSelector(direct, element.localName))) return direct
299
+ const path = []
300
+ for (let current = element; current instanceof Element && current.getRootNode() === root; current = current.parentElement) {
301
+ let part = selectorPart(current)
302
+ const siblings = current.parentElement ? Array.from(current.parentElement.children) : Array.from(root.children ?? [])
303
+ const sameTag = siblings.filter(sibling => sibling.localName === current.localName)
304
+ const index = sameTag.indexOf(current)
305
+ if (!specificSelector(part, current.localName) || sameTag.length > 1) part += ':nth-of-type(' + (index + 1) + ')'
306
+ path.unshift(part)
307
+ const candidate = path.join(' > ')
308
+ if (cp(candidate).length <= 500 && specificSelector(candidate, element.localName) && unique(candidate)) return candidate
309
+ }
310
+ const candidate = path.join(' > ')
311
+ return cp(candidate).length <= 500 ? candidate : cp(direct).length <= 500 ? direct : element.localName
95
312
  }
96
- const describe = element => {
97
- let selector = element.tagName.toLowerCase()
98
- if (element.id) selector = '#' + CSS.escape(element.id)
99
- else if (element.getAttribute('data-testid')) selector = '[data-testid="' + CSS.escape(element.getAttribute('data-testid')) + '"]'
313
+ const describe = (element, composed = false) => {
314
+ const elementName = name(element, composed)
315
+ const elementText = bounded(composed ? textComposed(element) : text(element), 500)
316
+ // At the top of a shadow root the context is the component that holds it.
317
+ const parent = composed ? element.parentElement ?? (element.parentNode instanceof ShadowRoot ? element.parentNode.host : null) : element.parentElement
318
+ const context = composed ? (allowedComposed(parent) ? textComposed(parent) : '') : (allowed(parent) ? text(parent) : '')
100
319
  return {
101
- kind: 'element', tag: element.tagName.toLowerCase(), role: bounded(role(element), 80),
102
- name: name(element), text: bounded(text(element), 500),
103
- context: bounded(allowed(element.parentElement) ? text(element.parentElement) : '', 200),
104
- selector: cp(selector).length <= 500 ? selector : element.tagName.toLowerCase()
320
+ kind: 'element', tag: element.localName, role: bounded(role(element), 80),
321
+ name: elementName, text: elementText, context: bounded(context, 200),
322
+ selector: selectorFor(element, Boolean(elementName || elementText)),
323
+ ...(composed ? { tree: 'composed' } : {})
105
324
  }
106
325
  }
107
326
  const rangeFor = (data, start, end) => {
@@ -138,11 +357,13 @@ export const PAGE_SCRIPT = String.raw `(() => {
138
357
  const resolve = target => {
139
358
  if (!target || typeof target !== 'object') throw new Error('Invalid target')
140
359
  if (target.kind === 'page') return { state: 'matched' }
360
+ if ('tree' in target && target.tree !== 'composed') throw new Error('Invalid target tree')
361
+ const composed = target.tree === 'composed'
141
362
  if (target.kind === 'text') {
142
363
  if (!validString(target.exact, 4000) || !target.exact || norm(target.exact) !== target.exact ||
143
364
  !validString(target.prefix, 128) || !validString(target.suffix, 128) ||
144
365
  !Number.isSafeInteger(target.start) || !Number.isSafeInteger(target.end) || target.start < 0 || target.end <= target.start) throw new Error('Invalid text target')
145
- const data = stream()
366
+ const data = composed ? streamComposed() : stream()
146
367
  const quote = cp(target.exact)
147
368
  const prefix = cp(target.prefix)
148
369
  const suffix = cp(target.suffix)
@@ -163,26 +384,72 @@ export const PAGE_SCRIPT = String.raw `(() => {
163
384
  if (matches.length > 1) return { state: 'ambiguous', reason: 'Quote and context match more than once' }
164
385
  }
165
386
  if (!matches.length) return { state: 'unresolved', reason: 'Quote or context changed or is unavailable' }
166
- return { state: 'matched', range: rangeFor(data, ...matches[0]) }
387
+ if (!composed) return { state: 'matched', range: rangeFor(data, ...matches[0]) }
388
+ const segments = segmentsFor(data, ...matches[0])
389
+ return segments.length ? { state: 'matched', segments } : { state: 'unresolved', reason: 'Quote or context changed or is unavailable' }
167
390
  }
168
391
  if (target.kind === 'element') {
169
392
  if (!validString(target.tag, 100) || !/^[a-z][a-z0-9-]*$/.test(target.tag) ||
170
393
  !validString(target.role, 200) || !validString(target.name, 200) || !validString(target.text, 500) ||
171
394
  !validString(target.context, 200) || !validString(target.selector, 500)) throw new Error('Invalid element target')
172
- if (!target.name && !target.text) return { state: 'unresolved', reason: 'No semantic evidence' }
173
- const elements = document.getElementsByTagName(target.tag)
395
+ const semantic = Boolean(target.name.trim() || target.text.trim())
396
+ const elements = composed ? byTagComposed(target.tag) : document.getElementsByTagName(target.tag)
174
397
  if (elements.length > 2000) return { state: 'unresolved', reason: 'Too many element candidates to resolve safely' }
175
- let candidates = Array.from(elements).filter(allowed).filter(element => {
176
- const candidate = describe(element)
398
+ if (specificSelector(target.selector, target.tag)) {
399
+ let anchored = []
400
+ try {
401
+ anchored = Array.from(elements).filter(composed ? allowedComposed : allowed).filter(element => element.matches(target.selector)).filter(element => {
402
+ const candidate = describe(element, composed)
403
+ return candidate.role === target.role && candidate.name === target.name && candidate.text === target.text && (!target.context || candidate.context === target.context)
404
+ })
405
+ } catch { anchored = [] }
406
+ if (anchored.length === 1) return { state: 'matched', element: anchored[0] }
407
+ if (anchored.length > 1 && !semantic) return { state: 'ambiguous', reason: 'The CSS selector matches more than one element' }
408
+ }
409
+ if (!semantic) return { state: 'unresolved', reason: 'No semantic evidence or unique CSS selector' }
410
+ let candidates = Array.from(elements).filter(composed ? allowedComposed : allowed).filter(element => {
411
+ const candidate = describe(element, composed)
177
412
  return candidate.role === target.role && candidate.name === target.name && candidate.text === target.text
178
413
  })
179
- if (candidates.length > 1 && target.context) candidates = candidates.filter(element => describe(element).context === target.context)
414
+ if (candidates.length > 1 && target.context) candidates = candidates.filter(element => describe(element, composed).context === target.context)
180
415
  if (candidates.length > 1) return { state: 'ambiguous', reason: 'Element semantics are duplicated' }
181
416
  if (!candidates.length) return { state: 'unresolved', reason: 'Element semantics changed or are unavailable' }
182
417
  return { state: 'matched', element: candidates[0] }
183
418
  }
184
419
  throw new Error('Unknown target kind')
185
420
  }
421
+ // What a pointer event points at: the nearest annotatable element on its composed path, or
422
+ // why there is none. Icons and other decorative parts give way to the control around them.
423
+ const pick = event => {
424
+ for (const node of event.composedPath()) {
425
+ if (node === document.body || node === document.documentElement || node === document || node === window) break
426
+ if (!(node instanceof Element)) continue
427
+ if (node.matches('iframe,object,embed')) return { element: null, refusal: 'Embedded frames can\u2019t be annotated.' }
428
+ if (node.matches(refusedContent)) return { element: null, refusal: 'Form fields and private content can\u2019t be annotated.' }
429
+ if (!allowedComposed(node)) continue
430
+ // A component that shows nothing script can read (a closed shadow root) gives nothing to anchor to.
431
+ if (node.localName.includes('-') && !node.shadowRoot && !norm(node.getAttribute('aria-label')) && !textComposed(node)) {
432
+ return { element: null, refusal: 'This component\u2019s contents are private and can\u2019t be annotated.' }
433
+ }
434
+ return { element: node, refusal: null }
435
+ }
436
+ return { element: null, refusal: null }
437
+ }
438
+ // The current selection as a target: through the composed tree when the browser can say
439
+ // where a selection inside a shadow root is, else by the document-only rules.
440
+ const currentSelection = () => {
441
+ if (typeof getSelection()?.getComposedRanges === 'function') return composedSelectionTarget()
442
+ const target = selectionTarget()
443
+ return target ? { target, range: getSelection().getRangeAt(0).cloneRange() } : null
444
+ }
445
+ const unionRect = rects => {
446
+ const list = rects.filter(rect => rect.width || rect.height)
447
+ if (!list.length) return new DOMRect(0, 0, 0, 0)
448
+ const left = Math.min(...list.map(rect => rect.left))
449
+ const top = Math.min(...list.map(rect => rect.top))
450
+ return new DOMRect(left, top, Math.max(...list.map(rect => rect.right)) - left, Math.max(...list.map(rect => rect.bottom)) - top)
451
+ }
452
+ const segmentRects = segments => segments.flatMap(range => Array.from(range.getClientRects()))
186
453
  const listen = (node, type, handler, options = true) => {
187
454
  node.addEventListener(type, handler, options)
188
455
  listeners.push(() => node.removeEventListener(type, handler, options))
@@ -206,14 +473,14 @@ export const PAGE_SCRIPT = String.raw `(() => {
206
473
  const match = resolve(mark)
207
474
  overlay?.remove()
208
475
  overlay = null
209
- if (match.state !== 'matched' || (!match.range && !match.element)) return
210
- const rects = match.range ? Array.from(match.range.getClientRects()) : [match.element.getBoundingClientRect()]
211
- // Clip marks to nested scrollports, not just the top-level viewport.
212
- const anchor = match.element ?? match.range.commonAncestorContainer
213
- let ancestor = anchor.nodeType === Node.ELEMENT_NODE ? anchor : anchor.parentElement
476
+ if (match.state !== 'matched' || (!match.range && !match.element && !match.segments?.length)) return
477
+ const rects = match.range ? Array.from(match.range.getClientRects()) : match.segments ? segmentRects(match.segments) : [match.element.getBoundingClientRect()]
478
+ // Clip marks to nested scrollports, not just the top-level viewport, including those inside components.
479
+ const anchor = match.element ?? match.range?.commonAncestorContainer ?? match.segments[0].startContainer
480
+ let ancestor = anchor.nodeType === Node.ELEMENT_NODE ? anchor : composedParent(anchor)
214
481
  let clip = { left: 0, top: 0, right: innerWidth, bottom: innerHeight }
215
- if (match.element) ancestor = ancestor.parentElement
216
- for (; ancestor; ancestor = ancestor.parentElement) {
482
+ if (match.element) ancestor = composedParent(ancestor)
483
+ for (; ancestor; ancestor = composedParent(ancestor)) {
217
484
  const style = getComputedStyle(ancestor)
218
485
  const rect = ancestor.getBoundingClientRect()
219
486
  if (/(auto|scroll|hidden|clip)/.test(style.overflowX)) {
@@ -254,6 +521,7 @@ export const PAGE_SCRIPT = String.raw `(() => {
254
521
  '.box.frozen{border-color:#f97316;background:rgba(249,115,22,.12)}' +
255
522
  '.chip{position:fixed;display:none;padding:2px 6px;border-radius:4px;background:#1e293b;color:#f8fafc;font-size:11px;white-space:nowrap;max-width:360px;overflow:hidden;text-overflow:ellipsis}' +
256
523
  '.chip b{font-weight:600;color:#93c5fd}' +
524
+ '.chip.refused{background:#334155;color:#e2e8f0;font-style:italic}' +
257
525
  '.pop{position:fixed;display:none;width:320px;box-sizing:border-box;padding:10px;border-radius:8px;pointer-events:auto;background:Canvas;color:CanvasText;border:1px solid rgba(127,127,127,.35);box-shadow:0 8px 28px rgba(0,0,0,.28)}' +
258
526
  '.what{font-size:11px;opacity:.7;margin:0 0 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
259
527
  'textarea{all:unset;display:block;box-sizing:border-box;width:100%;min-height:68px;max-height:200px;padding:6px 8px;border-radius:6px;border:1px solid rgba(127,127,127,.45);font:inherit;font-size:13px;white-space:pre-wrap;overflow:auto;background:Field;color:FieldText}' +
@@ -298,17 +566,37 @@ export const PAGE_SCRIPT = String.raw `(() => {
298
566
  '<div class="row"><button data-action="save">Create annotation</button><button data-action="queue">Ask agent to fix</button><button class="primary" data-action="send">Proceed now</button></div>' +
299
567
  '<div class="hint">Esc to cancel · ⌘↵ proceed now</div></div><div class="toast" role="status"></div></div>'
300
568
  const $ = selector => shadow.querySelector(selector)
301
- const state = { captureId, host, shadow, listeners: [], hovered: null, frozen: null, frame: 0, results: [], box: $('.box'), chip: $('.chip'), pop: $('.pop'), what: $('.what'), input: $('textarea'), toast: $('.toast'), toastTimer: 0 }
569
+ const state = { captureId, host, shadow, listeners: [], hovered: null, refusal: null, pointer: null, frozen: null, frame: 0, results: [], box: $('.box'), chip: $('.chip'), pop: $('.pop'), what: $('.what'), input: $('textarea'), toast: $('.toast'), toastTimer: 0 }
302
570
  inspect = state
303
571
  const on = (node, type, handler, options = true) => {
304
572
  node.addEventListener(type, handler, options)
305
573
  state.listeners.push(() => node.removeEventListener(type, handler, options))
306
574
  }
307
- const rectOf = picked => picked.range ? picked.range.getBoundingClientRect() : picked.element.getBoundingClientRect()
575
+ const rectOf = picked => picked.range ? picked.range.getBoundingClientRect() : picked.segments ? unionRect(segmentRects(picked.segments)) : picked.element.getBoundingClientRect()
576
+ // Scrolls inside a component do not reach the window, so listen on each shadow root met.
577
+ const watched = new WeakSet()
578
+ const watch = node => {
579
+ for (const root of rootsOf(node)) {
580
+ if (watched.has(root)) continue
581
+ watched.add(root)
582
+ on(root, 'scroll', () => repaint(), { capture: true, passive: true })
583
+ }
584
+ }
308
585
  const paint = () => {
309
586
  state.frame = 0
310
587
  const current = state.frozen ?? (state.hovered ? { element: state.hovered } : null)
311
- if (!current || (current.element && !current.element.isConnected)) {
588
+ state.chip.classList.remove('refused')
589
+ if (!current && state.refusal && state.pointer) {
590
+ // Say why nothing is outlined instead of staying silent.
591
+ state.box.style.display = 'none'
592
+ state.chip.textContent = state.refusal
593
+ state.chip.classList.add('refused')
594
+ state.chip.style.display = 'block'
595
+ place(state.chip, { left: state.pointer.x + 12, top: state.pointer.y + 14, bottom: state.pointer.y + 14 }, Math.min(360, state.chip.offsetWidth || 240), 20, 0)
596
+ return
597
+ }
598
+ const detached = current && (current.element ? !current.element.isConnected : current.segments ? !current.segments[0]?.startContainer.isConnected : false)
599
+ if (!current || detached) {
312
600
  state.box.style.display = state.chip.style.display = 'none'
313
601
  return
314
602
  }
@@ -338,6 +626,8 @@ export const PAGE_SCRIPT = String.raw `(() => {
338
626
  }
339
627
  const openPopover = picked => {
340
628
  state.frozen = picked
629
+ state.refusal = null
630
+ watch(picked.element ?? picked.segments?.[0]?.startContainer ?? picked.range?.startContainer)
341
631
  const target = picked.target
342
632
  state.what.textContent = target.kind === 'text' ? '“' + bounded(target.exact, 80) + '”' : '<' + target.tag + '> ' + (target.name || target.text || target.selector)
343
633
  state.pop.style.display = 'block'
@@ -368,12 +658,16 @@ export const PAGE_SCRIPT = String.raw `(() => {
368
658
  if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); submit(event.shiftKey ? 'queue' : 'send') }
369
659
  }, false)
370
660
  const inPopover = event => event.composedPath().includes(host)
371
- const pickable = element => element instanceof Element && allowed(element) && !element.tagName.includes('-')
372
661
  on(window, 'pointermove', event => {
373
662
  if (state.frozen || inPopover(event)) return
374
- const element = event.composedPath()[0]
375
- const next = pickable(element) ? element : null
376
- if (next !== state.hovered) { state.hovered = next; repaint() }
663
+ const found = pick(event)
664
+ state.pointer = { x: event.clientX, y: event.clientY }
665
+ if (found.element) watch(found.element)
666
+ if (found.element !== state.hovered || found.refusal !== state.refusal || found.refusal) {
667
+ state.hovered = found.element
668
+ state.refusal = found.refusal
669
+ repaint()
670
+ }
377
671
  }, { capture: true, passive: true })
378
672
  let down = null
379
673
  // The page never sees presses on what is being inspected; the browser may still select text.
@@ -381,7 +675,7 @@ export const PAGE_SCRIPT = String.raw `(() => {
381
675
  on(window, type, event => {
382
676
  if (!event.isTrusted || inPopover(event)) return
383
677
  event.stopImmediatePropagation()
384
- if (event.type === 'pointerdown' && event.button === 0) down = { x: event.clientX, y: event.clientY, element: event.composedPath()[0] }
678
+ if (event.type === 'pointerdown' && event.button === 0) down = { x: event.clientX, y: event.clientY, element: pick(event).element }
385
679
  }, { capture: true, passive: false })
386
680
  }
387
681
  for (const type of ['pointerup', 'mouseup', 'click', 'dblclick', 'auxclick', 'contextmenu', 'submit']) {
@@ -396,16 +690,18 @@ export const PAGE_SCRIPT = String.raw `(() => {
396
690
  // A drag selects text; the popover opens on the selection.
397
691
  if (!moved) return
398
692
  down = null
399
- let target = null
400
- try { target = selectionTarget() } catch { target = null }
401
- if (target) openPopover({ target, range: getSelection().getRangeAt(0).cloneRange() })
693
+ let selected = null
694
+ try { selected = currentSelection() } catch { selected = null }
695
+ if (selected) openPopover(selected)
696
+ else if (!getSelection()?.isCollapsed) showToast('This selection can\u2019t be annotated: it is too long or crosses a form field or private content.')
402
697
  return
403
698
  }
404
699
  if (event.type !== 'click' || moved) return
405
700
  down = null
406
- const element = event.composedPath()[0]
407
- if (element !== start.element || !pickable(element)) return
408
- openPopover({ target: describe(element), element })
701
+ const found = pick(event)
702
+ if (found.refusal) { showToast(found.refusal); return }
703
+ if (!found.element || found.element !== start.element) return
704
+ openPopover({ target: describe(found.element, true), element: found.element })
409
705
  }, { capture: true, passive: false })
410
706
  }
411
707
  on(window, 'keydown', event => {
@@ -455,22 +751,22 @@ export const PAGE_SCRIPT = String.raw `(() => {
455
751
  })
456
752
  if (pending.kind === 'text') {
457
753
  listen(document, 'selectionchange', () => {
458
- try { const target = selectionTarget(); if (target) picked = envelope(target) } catch { picked = null }
754
+ // A selection that grows over something refused must not leave the last acceptable one behind.
755
+ try { const selected = currentSelection(); picked = selected ? envelope(selected.target) : null } catch { picked = null }
459
756
  })
460
757
  }
461
758
  if (pending.kind === 'element') {
462
759
  let chosen = null
463
760
  const choose = event => {
464
761
  if (!event.isTrusted || event.button !== 0 || !pending || pending.kind !== 'element') return
465
- // Never traverse frames/shadow roots, including retargeted closed shadow events.
466
- const element = event.target
467
- if (!(element instanceof Element) || !allowed(element) || event.composedPath()[0] !== element) return
468
- if (element.tagName.includes('-')) return
762
+ // Open shadow roots are followed; frames and closed shadow roots never are.
763
+ const element = pick(event).element
764
+ if (!element) return
469
765
  event.preventDefault()
470
766
  event.stopImmediatePropagation()
471
767
  if (event.type === 'pointerdown') chosen = element
472
768
  if (event.type === 'click' && chosen === element) {
473
- picked = envelope(describe(element))
769
+ picked = envelope(describe(element, true))
474
770
  // Keep suppression through this click; capture/cancel removes the listeners.
475
771
  }
476
772
  }
@@ -480,7 +776,7 @@ export const PAGE_SCRIPT = String.raw `(() => {
480
776
  }
481
777
  case 'capture': {
482
778
  if (!pending || operation.captureId !== pending.captureId) return null
483
- const result = picked ?? (pending.kind === 'text' ? selectionTarget() : null)
779
+ const result = picked ?? (pending.kind === 'text' ? currentSelection()?.target ?? null : null)
484
780
  if (!result) return null
485
781
  const captured = picked ?? envelope(result)
486
782
  clear()
@@ -512,11 +808,15 @@ export const PAGE_SCRIPT = String.raw `(() => {
512
808
  pending = null
513
809
  picked = null
514
810
  if (match.state === 'matched' && operation.target.kind !== 'page') {
515
- const element = match.element ?? match.range.startContainer.parentElement
811
+ const start = (match.range ?? match.segments?.[0])?.startContainer
812
+ const element = match.element ?? start.parentElement ?? start.parentNode.host
516
813
  element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' })
517
814
  mark = operation.target
815
+ const roots = rootsOf(element)
518
816
  listen(window, 'scroll', schedule)
519
817
  listen(window, 'resize', schedule)
818
+ // Scrolls inside a component do not reach the window.
819
+ for (const root of roots) listen(root, 'scroll', schedule, { capture: true, passive: true })
520
820
  if (window.visualViewport) {
521
821
  listen(window.visualViewport, 'scroll', schedule)
522
822
  listen(window.visualViewport, 'resize', schedule)
@@ -535,6 +835,7 @@ export const PAGE_SCRIPT = String.raw `(() => {
535
835
  })) schedule()
536
836
  })
537
837
  mutations.observe(document.documentElement, { subtree: true, childList: true, characterData: true, attributes: true })
838
+ for (const root of roots) mutations.observe(root, { subtree: true, childList: true, characterData: true, attributes: true })
538
839
  }
539
840
  }
540
841
  return { state: match.state, ...(match.reason ? { reason: match.reason } : {}) }