@classic-homes/page-agent-core 0.1.0 → 0.2.1
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.
- package/README.md +56 -8
- package/dist/index.d.ts +207 -44
- package/dist/index.js +477 -36
- package/dist/styles.css +42 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,7 +37,8 @@ ignoreSelector })`.
|
|
|
37
37
|
- **Effects**: `highlight(ref, { style: 'outline' | 'spotlight', label,
|
|
38
38
|
durationMs })`, `point(ref, { label, durationMs })`, `scrollToTarget(ref)`,
|
|
39
39
|
`clearEffects()`, `getEffects()` / `onEffectsChange()` (safe for
|
|
40
|
-
`useSyncExternalStore`) and `
|
|
40
|
+
`useSyncExternalStore`), and `visibilityOf(effect)` / `rectOf(effect)`
|
|
41
|
+
for drawing. Effects expire
|
|
41
42
|
(default 8 s, max 60 s); at most 8 at once, one pointer and one spotlight.
|
|
42
43
|
`attachPageAgent()` wires Escape and `popstate` to clear; call
|
|
43
44
|
`notifyNavigated()` after SPA navigation.
|
|
@@ -45,8 +46,21 @@ durationMs })`, `point(ref, { label, durationMs })`, `scrollToTarget(ref)`,
|
|
|
45
46
|
ignoreSelector })` and `executePageCall(call)`, the single entry point for
|
|
46
47
|
the model's tool calls. `PAGE_TOOL_NAMES` and `PAGE_TOOLS` (plain-JSON
|
|
47
48
|
input shapes) let the agent server mirror the contract exactly.
|
|
48
|
-
- **
|
|
49
|
-
|
|
49
|
+
- **Visibility**: `measureVisibility(el)` says what of a target the user can
|
|
50
|
+
see: clipped to the viewport and its scroll boxes, cut where a strip
|
|
51
|
+
covers one side of it (a sticky header / footer / dock, or a menu over one
|
|
52
|
+
end: a cover spanning the target's visible part along one axis and
|
|
53
|
+
reaching past an edge on the other, viewport-wide or not; cuts combine),
|
|
54
|
+
drawn over smaller covers (hit-tested with `document.elementsFromPoint`
|
|
55
|
+
at a 3 x 3 grid plus a probe inside each edge, the layer skipped), and
|
|
56
|
+
`covered` under a cover that hides most of it, behind a modal dialog or in
|
|
57
|
+
`inert`. Bindings draw only `visible` and hide (not
|
|
58
|
+
remove) effects while it is null. The maths is the pure
|
|
59
|
+
`computeVisibility({ rect, viewport, clips, behindModal, coverAt })`.
|
|
60
|
+
- **Collapsed tab rows**: `collapsedTabMenu(tab)` and the
|
|
61
|
+
`data-tablist-menu` DOM contract (below).
|
|
62
|
+
- **Geometry**: `unionRects`, `inflate`, `clampToViewport`, `intersectBoxes`,
|
|
63
|
+
`placeLabel` (with optional `bounds`), `pointerTarget` for the UI layer.
|
|
50
64
|
|
|
51
65
|
```ts
|
|
52
66
|
import {
|
|
@@ -78,7 +92,7 @@ const result = await executePageCall({
|
|
|
78
92
|
| ------------------ | ----------------------------------------- | --------------------------------------------------------- |
|
|
79
93
|
| `list_targets` | `{ kind?, limit? }` | Named targets and tabs on the page |
|
|
80
94
|
| `highlight` | `{ target, style?, label?, durationMs? }` | Outline or spotlight, scrolling into view first if needed |
|
|
81
|
-
| `point` | `{ target, label?, durationMs? }` | Moves the ghost cursor
|
|
95
|
+
| `point` | `{ target, label?, durationMs? }` | Moves the ghost cursor, scrolling into view if needed |
|
|
82
96
|
| `scroll_to` | `{ target }` | Scrolls into view (no-op if already fully visible) |
|
|
83
97
|
| `clear_highlights` | `{}` | Removes every effect |
|
|
84
98
|
| `select_tab` | `{ target }` | Clicks a `role="tab"`, `ok` once `aria-selected="true"` |
|
|
@@ -88,6 +102,33 @@ const result = await executePageCall({
|
|
|
88
102
|
output? }` with `status` one of `ok`, `not_found`, `ambiguous`, `hidden`,
|
|
89
103
|
`refused`, `error`.
|
|
90
104
|
|
|
105
|
+
`highlight`, `point` and `scroll_to` wait for their scroll to settle, scroll
|
|
106
|
+
once more if a sticky header or footer still covers the target, and measure
|
|
107
|
+
`inViewport` after that. `highlight` / `point` on a target the user cannot
|
|
108
|
+
see because something covers it return `hidden` with `output.reason`
|
|
109
|
+
`'behind_dialog'` or `'covered'`, and show nothing.
|
|
110
|
+
|
|
111
|
+
## Collapsed tab rows
|
|
112
|
+
|
|
113
|
+
A tab widget that swaps its row for a drop-down when it does not fit (theme
|
|
114
|
+
`Tabs`, `collapse="auto"`) keeps the `role="tab"` buttons in the DOM,
|
|
115
|
+
invisible and `inert`, and marks the row while collapsed:
|
|
116
|
+
|
|
117
|
+
```html
|
|
118
|
+
<div role="tablist" data-tablist-menu="rec-menu">
|
|
119
|
+
<button role="tab" aria-selected="false">Documents</button>
|
|
120
|
+
</div>
|
|
121
|
+
<button id="rec-menu">Overview ▾</button>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`select_tab` then still clicks the hidden tab (browsers run a script
|
|
125
|
+
`click()` on inert elements), confirms `aria-selected="true"` and answers
|
|
126
|
+
with `output.shownAs: 'menu'` and a `message` that the tabs are a drop-down.
|
|
127
|
+
`highlight` / `point` on such a tab go to the visible stand-in (the
|
|
128
|
+
attribute's id). `listTabs()` reports them `visible` (while the stand-in is)
|
|
129
|
+
with `shownAs: 'menu'`. A tab is never treated as collapsed without the
|
|
130
|
+
attribute, and nothing but `role="tab"` is ever clicked.
|
|
131
|
+
|
|
91
132
|
## Security
|
|
92
133
|
|
|
93
134
|
`executePageCall` treats its input as untrusted:
|
|
@@ -96,12 +137,14 @@ output? }` with `status` one of `ok`, `not_found`, `ambiguous`, `hidden`,
|
|
|
96
137
|
unknown keys and malformed input return `refused`. It never throws.
|
|
97
138
|
- Targets are ids (compared verbatim with attribute values) or visible
|
|
98
139
|
text. No CSS selectors, HTML or code are accepted.
|
|
99
|
-
- Nothing is clicked except a non-disabled `role="tab"` element
|
|
140
|
+
- Nothing is clicked except a non-disabled `role="tab"` element (also in a
|
|
141
|
+
collapsed row; never its drop-down or any `<select>`).
|
|
100
142
|
- `navigate` accepts only paths starting with a single `/` (no `//`,
|
|
101
143
|
backslashes, schemes, whitespace or control characters, also after
|
|
102
144
|
decoding) and goes through the host's handler; without one it is refused.
|
|
103
145
|
- `ok` is reported only once the effect is in place (the effect is in the
|
|
104
|
-
store, the tab is selected, the host's
|
|
146
|
+
store and its target is not covered, the tab is selected, the host's
|
|
147
|
+
navigation resolved).
|
|
105
148
|
- `[data-agent-ignore]`, the host `ignoreSelector` and the layer's own UI
|
|
106
149
|
(`.chpa`) are invisible to resolution, snapshots and `readPageText`, which
|
|
107
150
|
also never reads password, hidden, card or one-time-code field values.
|
|
@@ -117,11 +160,16 @@ keeps the page clickable), the ghost cursor (`.chpa-pointer`,
|
|
|
117
160
|
Dismiss" chip (`.chpa-chip`, `.chpa-chip-text`, `.chpa-chip-btn`, 44 px
|
|
118
161
|
target) and a live region (`.chpa-sr`). Override `--chpa-accent` (default
|
|
119
162
|
`#6d28d9`) and `--chpa-z` (default `2147482000`, below the Access
|
|
120
|
-
Inspector).
|
|
163
|
+
Inspector). The layer stays on top so targets inside dialogs can be pointed
|
|
164
|
+
at; covers are handled by drawing only the visible part (an outline cut by
|
|
165
|
+
a header carries `data-cut="top"`, one cut by a menu over its right end
|
|
166
|
+
`data-cut="right"`, and is left open on that side). Dark mode follows `.dark` / `[data-theme='dark']`; reduced
|
|
121
167
|
motion drops the glide and pulse.
|
|
122
168
|
|
|
123
169
|
## Testing
|
|
124
170
|
|
|
125
171
|
jsdom has no layout, so pass `configurePageAgent({ measure })` to supply
|
|
126
172
|
element boxes. The style rules (`hidden`, `display: none`, `visibility`,
|
|
127
|
-
`opacity: 0`) still run for real.
|
|
173
|
+
`opacity: 0`) still run for real. jsdom has no hit testing either: nothing
|
|
174
|
+
counts as covered unless you pass `configurePageAgent({ hitTest: (x, y) =>
|
|
175
|
+
element | null })`.
|
package/dist/index.d.ts
CHANGED
|
@@ -129,6 +129,12 @@ interface TabSummary {
|
|
|
129
129
|
selected: boolean;
|
|
130
130
|
disabled: boolean;
|
|
131
131
|
visible: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* `menu` when the tab row is collapsed into a drop-down (classic-theme
|
|
134
|
+
* `Tabs` on a narrow page): `select_tab` still works, but the user sees a
|
|
135
|
+
* menu showing the selected tab, not a row of tabs.
|
|
136
|
+
*/
|
|
137
|
+
shownAs?: 'menu';
|
|
132
138
|
}
|
|
133
139
|
interface Candidate {
|
|
134
140
|
id?: string;
|
|
@@ -148,6 +154,13 @@ type NavigateHandler = (path: string) => void | boolean | Promise<void | boolean
|
|
|
148
154
|
* `visibility`, `hidden`, `inert`) always run regardless.
|
|
149
155
|
*/
|
|
150
156
|
type MeasureElement = (el: Element) => DOMRect | null;
|
|
157
|
+
/**
|
|
158
|
+
* Hit test: the topmost element drawn at a viewport point, or null. The
|
|
159
|
+
* default uses `document.elementsFromPoint()` and skips the layer's own UI
|
|
160
|
+
* (`.chpa`); without it (jsdom), nothing counts as covered. Tests and
|
|
161
|
+
* unusual hosts (canvas overlays, iframes) can swap it.
|
|
162
|
+
*/
|
|
163
|
+
type HitTestPoint = (x: number, y: number) => Element | null;
|
|
151
164
|
interface PageAgentConfig {
|
|
152
165
|
/** Called by the `navigate` tool. Without it, `navigate` is refused. */
|
|
153
166
|
navigate?: NavigateHandler;
|
|
@@ -157,6 +170,8 @@ interface PageAgentConfig {
|
|
|
157
170
|
ignoreSelector?: string;
|
|
158
171
|
/** Layout reader; see {@link MeasureElement}. */
|
|
159
172
|
measure?: MeasureElement;
|
|
173
|
+
/** Hit test used to tell whether a target is covered; see {@link HitTestPoint}. */
|
|
174
|
+
hitTest?: HitTestPoint;
|
|
160
175
|
/** How long `select_tab` waits for `aria-selected="true"`. Default 600 ms, max 5000. */
|
|
161
176
|
tabSettleMs?: number;
|
|
162
177
|
}
|
|
@@ -165,13 +180,17 @@ interface ResolvedPageAgentConfig {
|
|
|
165
180
|
aliasAttributes: string[];
|
|
166
181
|
ignoreSelector: string | null;
|
|
167
182
|
measure: MeasureElement | null;
|
|
183
|
+
hitTest: HitTestPoint | null;
|
|
168
184
|
tabSettleMs: number;
|
|
169
185
|
}
|
|
170
186
|
type ResolveResult = {
|
|
171
187
|
status: 'ok';
|
|
188
|
+
/** The element to act on (for a collapsed tab: the tab to click, or the menu to point at). */
|
|
172
189
|
el: Element;
|
|
173
190
|
id?: string;
|
|
174
191
|
label: string;
|
|
192
|
+
/** The target is a tab in a collapsed row, shown to the user as a drop-down menu. */
|
|
193
|
+
shownAs?: 'menu';
|
|
175
194
|
} | {
|
|
176
195
|
status: 'not_found';
|
|
177
196
|
message: string;
|
|
@@ -195,6 +214,143 @@ type ResolveFailure = Exclude<ResolveResult, {
|
|
|
195
214
|
declare function configurePageAgent(next: PageAgentConfig): ResolvedPageAgentConfig;
|
|
196
215
|
declare function getPageAgentConfig(): ResolvedPageAgentConfig;
|
|
197
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Pure rect maths for drawing effects. No DOM access: callers pass the
|
|
219
|
+
* viewport size (see `viewportSize()` on the UI side).
|
|
220
|
+
*/
|
|
221
|
+
interface Box {
|
|
222
|
+
top: number;
|
|
223
|
+
left: number;
|
|
224
|
+
width: number;
|
|
225
|
+
height: number;
|
|
226
|
+
}
|
|
227
|
+
interface Size {
|
|
228
|
+
width: number;
|
|
229
|
+
height: number;
|
|
230
|
+
}
|
|
231
|
+
interface Point {
|
|
232
|
+
top: number;
|
|
233
|
+
left: number;
|
|
234
|
+
}
|
|
235
|
+
type RectLike = Pick<DOMRect, 'top' | 'left' | 'width' | 'height'>;
|
|
236
|
+
/** Smallest box covering every rect; null for none. */
|
|
237
|
+
declare function unionRects(rects: readonly RectLike[]): Box | null;
|
|
238
|
+
/** Grow a box by `pad` on every side (outline breathing room). */
|
|
239
|
+
declare function inflate(rect: RectLike, pad: number): Box;
|
|
240
|
+
/** The part of a box inside the viewport (minus `margin`); null when none is. */
|
|
241
|
+
declare function clampToViewport(rect: RectLike, viewport: Size, margin?: number): Box | null;
|
|
242
|
+
/** Wholly inside the viewport. */
|
|
243
|
+
declare function isFullyInViewport(rect: RectLike, viewport: Size): boolean;
|
|
244
|
+
/** At least partly inside the viewport. */
|
|
245
|
+
declare function intersectsViewport(rect: RectLike, viewport: Size): boolean;
|
|
246
|
+
interface LabelPlacement extends Point {
|
|
247
|
+
placement: 'above' | 'below';
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Where to put a caption of `size` next to a target rect: above its
|
|
251
|
+
* top-left corner, or below when there is no room above (or `preferBelow`),
|
|
252
|
+
* then clamped into the viewport. Same rules as feedback-core's selection
|
|
253
|
+
* button, re-implemented to keep this package dependency-free.
|
|
254
|
+
*/
|
|
255
|
+
declare function placeLabel(rect: RectLike, size: Size, viewport: Size, opts?: {
|
|
256
|
+
gap?: number;
|
|
257
|
+
preferBelow?: boolean;
|
|
258
|
+
bounds?: RectLike | null;
|
|
259
|
+
}): LabelPlacement;
|
|
260
|
+
/**
|
|
261
|
+
* Where the ghost cursor's tip should rest: just inside the target's
|
|
262
|
+
* bottom-right quarter, clamped into the viewport so it never points off
|
|
263
|
+
* screen.
|
|
264
|
+
*/
|
|
265
|
+
declare function pointerTarget(rect: RectLike, viewport: Size, margin?: number): Point;
|
|
266
|
+
/** Overlap of two boxes; null when they do not overlap. */
|
|
267
|
+
declare function intersectBoxes(a: RectLike, b: RectLike): Box | null;
|
|
268
|
+
/** Which sides of a target are cut off (by clipping, the viewport or a covering strip). */
|
|
269
|
+
interface CutSides {
|
|
270
|
+
top: boolean;
|
|
271
|
+
right: boolean;
|
|
272
|
+
bottom: boolean;
|
|
273
|
+
left: boolean;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* - `visible`: wholly visible.
|
|
277
|
+
* - `partial`: partly visible (`visible` is that part).
|
|
278
|
+
* - `covered`: on screen but under something else (a dialog, a sticky header).
|
|
279
|
+
* - `clipped`: hidden by a scroll box / `overflow` ancestor.
|
|
280
|
+
* - `offscreen`: outside the viewport.
|
|
281
|
+
* - `unrendered`: no layout box.
|
|
282
|
+
*/
|
|
283
|
+
type VisibilityState = 'visible' | 'partial' | 'covered' | 'clipped' | 'offscreen' | 'unrendered';
|
|
284
|
+
interface TargetVisibility {
|
|
285
|
+
state: VisibilityState;
|
|
286
|
+
/** The target's whole layout box, in viewport px; null when unrendered. */
|
|
287
|
+
rect: Box | null;
|
|
288
|
+
/** The part the user can see; null unless `state` is `visible` or `partial`. */
|
|
289
|
+
visible: Box | null;
|
|
290
|
+
/**
|
|
291
|
+
* The area the target is shown in: the viewport, narrowed by clipping
|
|
292
|
+
* ancestors and by the strips (sticky header, footer, side dock, a menu
|
|
293
|
+
* over one end) that cut it. Captions are kept inside it. Null when there
|
|
294
|
+
* is none.
|
|
295
|
+
*/
|
|
296
|
+
bounds: Box | null;
|
|
297
|
+
cut: CutSides;
|
|
298
|
+
/** With `covered`: a modal dialog / inert page, or another element on top. */
|
|
299
|
+
coveredBy?: 'modal' | 'element';
|
|
300
|
+
/**
|
|
301
|
+
* Strips that cut the target (or a header-like cover over all of it), as
|
|
302
|
+
* measured: sticky headers and footers, docks, menus over one side.
|
|
303
|
+
*/
|
|
304
|
+
bands: Box[];
|
|
305
|
+
}
|
|
306
|
+
interface VisibilityInput {
|
|
307
|
+
/** The target's layout box; null when it has none. */
|
|
308
|
+
rect: RectLike | null;
|
|
309
|
+
viewport: Size;
|
|
310
|
+
/** Boxes of clipping ancestors (scroll boxes, `overflow: hidden`), innermost first. */
|
|
311
|
+
clips?: readonly RectLike[];
|
|
312
|
+
/** The target is behind a modal dialog or inside an `inert` subtree. */
|
|
313
|
+
behindModal?: boolean;
|
|
314
|
+
/**
|
|
315
|
+
* Hit test: the box of whatever is drawn on top of the target at this
|
|
316
|
+
* viewport point, or null when the target itself (or nothing) is.
|
|
317
|
+
*/
|
|
318
|
+
coverAt?: (x: number, y: number) => RectLike | null;
|
|
319
|
+
/**
|
|
320
|
+
* Sample grid per side (default 3, so 9 points), plus one probe just
|
|
321
|
+
* inside the middle of each edge of the visible part (see
|
|
322
|
+
* {@link computeVisibility}).
|
|
323
|
+
*/
|
|
324
|
+
grid?: number;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Where the user can actually see a target: its box intersected with the
|
|
328
|
+
* viewport and every clipping ancestor, then hit-tested at a grid of points.
|
|
329
|
+
*
|
|
330
|
+
* Occlusion rules, chosen to be robust with coarse sampling:
|
|
331
|
+
* - something that covers the whole visible part (a dialog backdrop) hides it;
|
|
332
|
+
* - a side strip, a cover that spans the visible part's full height and
|
|
333
|
+
* reaches past its left or right edge (a side dock, a menu over the end of
|
|
334
|
+
* a heading), or its full width and past its top or bottom edge (a sticky
|
|
335
|
+
* header or footer), cuts the target off exactly at the cover's edge, so
|
|
336
|
+
* the effect stops where the cover starts. The cover does not have to
|
|
337
|
+
* span the viewport, and cuts on several sides combine;
|
|
338
|
+
* - anything else on top (a floating button over a corner, a popover over
|
|
339
|
+
* the middle) hides the target only when it covers most of the grid
|
|
340
|
+
* points left uncut, and is otherwise drawn over: clipping around
|
|
341
|
+
* arbitrary shapes is not worth the wrong guesses.
|
|
342
|
+
*
|
|
343
|
+
* Sampling: a `grid` x `grid` set of points over the visible part (weighs
|
|
344
|
+
* covers for the "most of it" rule), plus a probe just inside the middle of
|
|
345
|
+
* each edge (finds side strips the grid falls short of, such as a menu over
|
|
346
|
+
* the last tenth of a wide heading: any strip of an edge's full length
|
|
347
|
+
* contains that edge's midpoint). After each cut the new edges are probed
|
|
348
|
+
* again, so a strip that only spans the part below a header is found too.
|
|
349
|
+
* Edge probes only find strips; they never count towards "most of it".
|
|
350
|
+
* 9 + 4 hit tests for an unobstructed target, 4 more per cut.
|
|
351
|
+
*/
|
|
352
|
+
declare function computeVisibility(input: VisibilityInput): TargetVisibility;
|
|
353
|
+
|
|
198
354
|
declare const EFFECT_DEFAULT_MS = 8000;
|
|
199
355
|
declare const EFFECT_MIN_MS = 1000;
|
|
200
356
|
declare const EFFECT_MAX_MS = 60000;
|
|
@@ -235,6 +391,13 @@ declare function getEffects(): readonly PageEffect[];
|
|
|
235
391
|
declare function onEffectsChange(listener: () => void): () => void;
|
|
236
392
|
/** Viewport rect of an effect's element, or null when it has no layout box (skip drawing). */
|
|
237
393
|
declare function rectOf(effect: PageEffect): DOMRect | null;
|
|
394
|
+
/**
|
|
395
|
+
* What of an effect's element the user can see right now (clipped by
|
|
396
|
+
* scroll boxes, cut under sticky headers, hidden behind dialogs). Bindings
|
|
397
|
+
* draw `visible` and hide the effect, without removing it, while it is
|
|
398
|
+
* null. See `measureVisibility`.
|
|
399
|
+
*/
|
|
400
|
+
declare function visibilityOf(effect: PageEffect): TargetVisibility;
|
|
238
401
|
/** Tell the agent layer the route changed (SPA navigation): clears every effect. */
|
|
239
402
|
declare function notifyNavigated(): void;
|
|
240
403
|
/**
|
|
@@ -251,6 +414,14 @@ declare function attachPageAgent(): () => void;
|
|
|
251
414
|
* Returns whether anything scrolled.
|
|
252
415
|
*/
|
|
253
416
|
declare function scrollIntoContainers(el: Element): boolean;
|
|
417
|
+
/**
|
|
418
|
+
* After {@link scrollIntoContainers}: when a sticky header or footer still
|
|
419
|
+
* covers part of the target (`visibility.bands`), scroll its nearest
|
|
420
|
+
* vertical scroll container (else the window) so the target sits in the
|
|
421
|
+
* middle of the uncovered area (`visibility.bounds`), or starts at its top
|
|
422
|
+
* when it is taller. Returns whether anything scrolled.
|
|
423
|
+
*/
|
|
424
|
+
declare function nudgeIntoBounds(el: Element, visibility: TargetVisibility): boolean;
|
|
254
425
|
|
|
255
426
|
/**
|
|
256
427
|
* A same-origin app path, normalised, or null. Accepts only paths starting
|
|
@@ -301,6 +472,9 @@ interface ResolveOptions {
|
|
|
301
472
|
/**
|
|
302
473
|
* Only `role="tab"` elements count (for `select_tab`): a text match is
|
|
303
474
|
* promoted to its enclosing tab, and matches outside any tab are dropped.
|
|
475
|
+
* A tab in a collapsed row (see `tabs.ts`) counts as visible while its
|
|
476
|
+
* stand-in menu is, and resolves to the tab itself (the thing to click).
|
|
477
|
+
* Without this flag such a tab resolves to the visible menu instead.
|
|
304
478
|
*/
|
|
305
479
|
tabsOnly?: boolean;
|
|
306
480
|
}
|
|
@@ -328,7 +502,11 @@ declare function listTargets(options?: {
|
|
|
328
502
|
kind?: AgentTargetKind;
|
|
329
503
|
limit?: number;
|
|
330
504
|
}): TargetSummary[];
|
|
331
|
-
/**
|
|
505
|
+
/**
|
|
506
|
+
* Every `[role="tab"]` outside ignored regions, with label and state. A tab
|
|
507
|
+
* in a collapsed row (see `tabs.ts`) is `visible` while its stand-in menu
|
|
508
|
+
* is, and carries `shownAs: 'menu'`.
|
|
509
|
+
*/
|
|
332
510
|
declare function listTabs(options?: {
|
|
333
511
|
limit?: number;
|
|
334
512
|
}): TabSummary[];
|
|
@@ -402,55 +580,40 @@ declare function viewportSize(): ViewportSize;
|
|
|
402
580
|
declare function prefersReducedMotion(): boolean;
|
|
403
581
|
|
|
404
582
|
/**
|
|
405
|
-
*
|
|
406
|
-
*
|
|
583
|
+
* Boxes of the ancestors that clip `el` (any `overflow` other than
|
|
584
|
+
* `visible`), innermost first, up to the first `position: fixed` one (its
|
|
585
|
+
* ancestors do not clip it). Absolutely positioned elements that escape a
|
|
586
|
+
* non-positioned clipper are over-clipped; menus and popovers are usually
|
|
587
|
+
* portalled, so this is rare.
|
|
407
588
|
*/
|
|
408
|
-
|
|
409
|
-
top: number;
|
|
410
|
-
left: number;
|
|
411
|
-
width: number;
|
|
412
|
-
height: number;
|
|
413
|
-
}
|
|
414
|
-
interface Size {
|
|
415
|
-
width: number;
|
|
416
|
-
height: number;
|
|
417
|
-
}
|
|
418
|
-
interface Point {
|
|
419
|
-
top: number;
|
|
420
|
-
left: number;
|
|
421
|
-
}
|
|
422
|
-
type RectLike = Pick<DOMRect, 'top' | 'left' | 'width' | 'height'>;
|
|
423
|
-
/** Smallest box covering every rect; null for none. */
|
|
424
|
-
declare function unionRects(rects: readonly RectLike[]): Box | null;
|
|
425
|
-
/** Grow a box by `pad` on every side (outline breathing room). */
|
|
426
|
-
declare function inflate(rect: RectLike, pad: number): Box;
|
|
427
|
-
/** The part of a box inside the viewport (minus `margin`); null when none is. */
|
|
428
|
-
declare function clampToViewport(rect: RectLike, viewport: Size, margin?: number): Box | null;
|
|
429
|
-
/** Wholly inside the viewport. */
|
|
430
|
-
declare function isFullyInViewport(rect: RectLike, viewport: Size): boolean;
|
|
431
|
-
/** At least partly inside the viewport. */
|
|
432
|
-
declare function intersectsViewport(rect: RectLike, viewport: Size): boolean;
|
|
433
|
-
interface LabelPlacement extends Point {
|
|
434
|
-
placement: 'above' | 'below';
|
|
435
|
-
}
|
|
589
|
+
declare function clippingBoxes(el: Element): Box[];
|
|
436
590
|
/**
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
591
|
+
* Inside an `inert` subtree, or not inside the topmost open modal dialog
|
|
592
|
+
* (`aria-modal="true"` or a `<dialog>` opened with `showModal()`). Modals
|
|
593
|
+
* that are not rendered (closed, `display: none`) and the layer's own UI do
|
|
594
|
+
* not count. Topmost = last in document order, which is where portalled
|
|
595
|
+
* dialogs land.
|
|
441
596
|
*/
|
|
442
|
-
declare function
|
|
443
|
-
gap?: number;
|
|
444
|
-
preferBelow?: boolean;
|
|
445
|
-
}): LabelPlacement;
|
|
597
|
+
declare function isBehindModal(el: Element): boolean;
|
|
446
598
|
/**
|
|
447
|
-
* Where the
|
|
448
|
-
*
|
|
449
|
-
*
|
|
599
|
+
* Where (and whether) the user can see `el` right now: clipped to the
|
|
600
|
+
* viewport and its scroll boxes, with covers (sticky headers, dialogs,
|
|
601
|
+
* docks) hit-tested. See {@link computeVisibility} for the rules. Style
|
|
602
|
+
* checks (`hidden`, `display: none`, …) are not repeated here; callers
|
|
603
|
+
* check {@link isRendered} first. Cheap enough per animation frame for a
|
|
604
|
+
* handful of effects (13 hit tests each, 4 more per cut side; reads only).
|
|
450
605
|
*/
|
|
451
|
-
declare function
|
|
606
|
+
declare function measureVisibility(el: Element): TargetVisibility;
|
|
607
|
+
|
|
608
|
+
declare const TABLIST_MENU_ATTR = "data-tablist-menu";
|
|
609
|
+
/**
|
|
610
|
+
* For a tab in a collapsed tab row: the visible stand-in control, or null
|
|
611
|
+
* when the row is collapsed but its stand-in is missing, ignored or not
|
|
612
|
+
* rendered. `undefined` when the tab is not in a collapsed row.
|
|
613
|
+
*/
|
|
614
|
+
declare function collapsedTabMenu(tab: Element): Element | null | undefined;
|
|
452
615
|
|
|
453
616
|
/** Back to a clean slate: default config, no effects. Registrations are kept (their owners destroy them). */
|
|
454
617
|
declare function resetPageAgent(): void;
|
|
455
618
|
|
|
456
|
-
export { type AgentTarget, type AgentTargetHandle, type AgentTargetKind, type Box, CALL_LABEL_MAX, CANDIDATES_MAX, type Candidate, EFFECTS_MAX, EFFECT_DEFAULT_MS, EFFECT_LABEL_MAX, EFFECT_MAX_MS, EFFECT_MIN_MS, type EffectKind, type EffectOutcome, type HighlightInput, type HighlightOptions, type HighlightStyle, type JsonShape, LAYER_CLASS, LIST_MAX, type LabelPlacement, type ListTargetsInput, type ListTargetsOutput, type MeasureElement, NAVIGATE_PATH_MAX, type NavigateHandler, type NavigateInput, PAGE_TEXT_DEFAULT, PAGE_TEXT_MAX, PAGE_TOOLS, PAGE_TOOL_NAMES, type PageAgentConfig, type PageCall, type PageCallResult, type PageCallStatus, type PageEffect, type PageToolDescription, type PageToolName, type Point, type PointInput, type PointOptions, type ReadPageTextOptions, type RenderCache, type ResolveFailure, type ResolveOptions, type ResolveResult, type ResolvedPageAgentConfig, type ScrollOutcome, type ScrollToInput, type SelectTabInput, type Size, TARGET_ID_MAX, TARGET_LABEL_MAX, TARGET_TEXT_MAX, TRUNCATION_MARKER, type TabSummary, type TargetRef, type TargetSummary, type ViewportSize, attachPageAgent, clampDuration, clampToViewport, clearEffects, configurePageAgent, executePageCall, getAgentTargets, getEffects, getPageAgentConfig, highlight, inflate, intersectsViewport, isFullyInViewport, isRendered, listTabs, listTargets, notifyNavigated, onAgentTargetsChange, onEffectsChange, parseAppPath, placeLabel, point, pointerTarget, prefersReducedMotion, readPageText, rectOf, registerAgentTarget, resetPageAgent, resolveTarget, scanAgentAttributes, scrollIntoContainers, scrollToTarget, targetIdOf, unionRects, viewportSize };
|
|
619
|
+
export { type AgentTarget, type AgentTargetHandle, type AgentTargetKind, type Box, CALL_LABEL_MAX, CANDIDATES_MAX, type Candidate, type CutSides, EFFECTS_MAX, EFFECT_DEFAULT_MS, EFFECT_LABEL_MAX, EFFECT_MAX_MS, EFFECT_MIN_MS, type EffectKind, type EffectOutcome, type HighlightInput, type HighlightOptions, type HighlightStyle, type HitTestPoint, type JsonShape, LAYER_CLASS, LIST_MAX, type LabelPlacement, type ListTargetsInput, type ListTargetsOutput, type MeasureElement, NAVIGATE_PATH_MAX, type NavigateHandler, type NavigateInput, PAGE_TEXT_DEFAULT, PAGE_TEXT_MAX, PAGE_TOOLS, PAGE_TOOL_NAMES, type PageAgentConfig, type PageCall, type PageCallResult, type PageCallStatus, type PageEffect, type PageToolDescription, type PageToolName, type Point, type PointInput, type PointOptions, type ReadPageTextOptions, type RenderCache, type ResolveFailure, type ResolveOptions, type ResolveResult, type ResolvedPageAgentConfig, type ScrollOutcome, type ScrollToInput, type SelectTabInput, type Size, TABLIST_MENU_ATTR, TARGET_ID_MAX, TARGET_LABEL_MAX, TARGET_TEXT_MAX, TRUNCATION_MARKER, type TabSummary, type TargetRef, type TargetSummary, type TargetVisibility, type ViewportSize, type VisibilityInput, type VisibilityState, attachPageAgent, clampDuration, clampToViewport, clearEffects, clippingBoxes, collapsedTabMenu, computeVisibility, configurePageAgent, executePageCall, getAgentTargets, getEffects, getPageAgentConfig, highlight, inflate, intersectBoxes, intersectsViewport, isBehindModal, isFullyInViewport, isRendered, listTabs, listTargets, measureVisibility, notifyNavigated, nudgeIntoBounds, onAgentTargetsChange, onEffectsChange, parseAppPath, placeLabel, point, pointerTarget, prefersReducedMotion, readPageText, rectOf, registerAgentTarget, resetPageAgent, resolveTarget, scanAgentAttributes, scrollIntoContainers, scrollToTarget, targetIdOf, unionRects, viewportSize, visibilityOf };
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ var defaults = () => ({
|
|
|
7
7
|
aliasAttributes: [],
|
|
8
8
|
ignoreSelector: null,
|
|
9
9
|
measure: null,
|
|
10
|
+
hitTest: null,
|
|
10
11
|
tabSettleMs: DEFAULT_TAB_SETTLE_MS
|
|
11
12
|
});
|
|
12
13
|
var config = defaults();
|
|
@@ -25,6 +26,7 @@ function configurePageAgent(next) {
|
|
|
25
26
|
merged.ignoreSelector = validSelector(next.ignoreSelector) ? next.ignoreSelector.trim() : null;
|
|
26
27
|
}
|
|
27
28
|
if ("measure" in next) merged.measure = typeof next.measure === "function" ? next.measure : null;
|
|
29
|
+
if ("hitTest" in next) merged.hitTest = typeof next.hitTest === "function" ? next.hitTest : null;
|
|
28
30
|
if (next.tabSettleMs !== void 0 && Number.isFinite(next.tabSettleMs)) {
|
|
29
31
|
merged.tabSettleMs = Math.min(Math.max(Math.round(next.tabSettleMs), 0), MAX_TAB_SETTLE_MS);
|
|
30
32
|
}
|
|
@@ -182,11 +184,20 @@ function intersectsViewport(rect, viewport) {
|
|
|
182
184
|
function placeLabel(rect, size, viewport, opts = {}) {
|
|
183
185
|
const { gap = 8, preferBelow = false } = opts;
|
|
184
186
|
const box = toBox(rect);
|
|
185
|
-
const
|
|
187
|
+
const area = opts.bounds && intersectBoxes(opts.bounds, viewportBox(viewport)) || viewportBox(viewport);
|
|
188
|
+
const areaBottom = area.top + area.height;
|
|
189
|
+
const areaRight = area.left + area.width;
|
|
190
|
+
const fitsAbove = box.top - size.height - gap >= area.top;
|
|
186
191
|
const above = !preferBelow && fitsAbove;
|
|
187
192
|
let top = above ? box.top - size.height - gap : box.top + box.height + gap;
|
|
188
|
-
top = Math.min(
|
|
189
|
-
|
|
193
|
+
top = Math.min(
|
|
194
|
+
Math.max(top, area.top + gap),
|
|
195
|
+
Math.max(area.top + gap, areaBottom - size.height - gap)
|
|
196
|
+
);
|
|
197
|
+
const left = Math.min(
|
|
198
|
+
Math.max(box.left, area.left + gap),
|
|
199
|
+
Math.max(area.left + gap, areaRight - size.width - gap)
|
|
200
|
+
);
|
|
190
201
|
return { top: Math.round(top), left: Math.round(left), placement: above ? "above" : "below" };
|
|
191
202
|
}
|
|
192
203
|
function pointerTarget(rect, viewport, margin = 12) {
|
|
@@ -198,6 +209,171 @@ function pointerTarget(rect, viewport, margin = 12) {
|
|
|
198
209
|
left: Math.round(Math.min(Math.max(left, margin), viewport.width - margin))
|
|
199
210
|
};
|
|
200
211
|
}
|
|
212
|
+
var EDGE = 1;
|
|
213
|
+
var MIN_VISIBLE = 4;
|
|
214
|
+
var PROBE_INSET = 2;
|
|
215
|
+
var MAX_TRIM_ROUNDS = 6;
|
|
216
|
+
var viewportBox = (viewport) => ({
|
|
217
|
+
top: 0,
|
|
218
|
+
left: 0,
|
|
219
|
+
width: viewport.width,
|
|
220
|
+
height: viewport.height
|
|
221
|
+
});
|
|
222
|
+
function intersectBoxes(a, b) {
|
|
223
|
+
const top = Math.max(a.top, b.top);
|
|
224
|
+
const left = Math.max(a.left, b.left);
|
|
225
|
+
const right = Math.min(a.left + a.width, b.left + b.width);
|
|
226
|
+
const bottom = Math.min(a.top + a.height, b.top + b.height);
|
|
227
|
+
if (right <= left || bottom <= top) return null;
|
|
228
|
+
return { top, left, width: right - left, height: bottom - top };
|
|
229
|
+
}
|
|
230
|
+
function containsBox(outer, inner) {
|
|
231
|
+
return outer.top <= inner.top + EDGE && outer.left <= inner.left + EDGE && outer.top + outer.height >= inner.top + inner.height - EDGE && outer.left + outer.width >= inner.left + inner.width - EDGE;
|
|
232
|
+
}
|
|
233
|
+
var containsPoint = (box, x, y) => x >= box.left && x <= box.left + box.width && y >= box.top && y <= box.top + box.height;
|
|
234
|
+
var NO_CUT = { top: false, right: false, bottom: false, left: false };
|
|
235
|
+
function computeVisibility(input) {
|
|
236
|
+
if (!input.rect) {
|
|
237
|
+
return { state: "unrendered", rect: null, visible: null, bounds: null, cut: NO_CUT, bands: [] };
|
|
238
|
+
}
|
|
239
|
+
const rect = toBox(input.rect);
|
|
240
|
+
const vp = viewportBox(input.viewport);
|
|
241
|
+
let bounds = vp;
|
|
242
|
+
for (const clip of input.clips ?? []) {
|
|
243
|
+
bounds = intersectBoxes(bounds, clip);
|
|
244
|
+
if (!bounds) break;
|
|
245
|
+
}
|
|
246
|
+
const result = (state, visible2, extra = {}) => ({
|
|
247
|
+
state,
|
|
248
|
+
rect,
|
|
249
|
+
visible: visible2,
|
|
250
|
+
bounds,
|
|
251
|
+
cut: NO_CUT,
|
|
252
|
+
bands: [],
|
|
253
|
+
...extra
|
|
254
|
+
});
|
|
255
|
+
if (!intersectBoxes(rect, vp)) return result("offscreen", null);
|
|
256
|
+
if (input.behindModal) return result("covered", null, { coveredBy: "modal" });
|
|
257
|
+
let visible = bounds ? intersectBoxes(rect, bounds) : null;
|
|
258
|
+
if (!visible || visible.width < MIN_VISIBLE || visible.height < MIN_VISIBLE) {
|
|
259
|
+
return result("clipped", null);
|
|
260
|
+
}
|
|
261
|
+
const bands = [];
|
|
262
|
+
if (input.coverAt) {
|
|
263
|
+
const n = Math.min(Math.max(Math.round(input.grid ?? 3), 1), 5);
|
|
264
|
+
const samples = [];
|
|
265
|
+
for (let row = 0; row < n; row++) {
|
|
266
|
+
for (let col = 0; col < n; col++) {
|
|
267
|
+
const x = visible.left + visible.width * (col + 0.5) / n;
|
|
268
|
+
const y = visible.top + visible.height * (row + 0.5) / n;
|
|
269
|
+
const hit = input.coverAt(x, y);
|
|
270
|
+
const cover = hit ? toBox(hit) : null;
|
|
271
|
+
samples.push({ x, y, cover: cover && containsPoint(cover, x, y) ? cover : null });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const covers = [];
|
|
275
|
+
const addCover = (cover) => {
|
|
276
|
+
if (cover && !covers.some((c) => sameBox(c, cover))) covers.push(cover);
|
|
277
|
+
};
|
|
278
|
+
for (const s of samples) addCover(s.cover);
|
|
279
|
+
const probed = /* @__PURE__ */ new Set();
|
|
280
|
+
const probeEdges = (box) => {
|
|
281
|
+
for (const [x, y] of edgeProbes(box)) {
|
|
282
|
+
const key = `${Math.round(x * 2)},${Math.round(y * 2)}`;
|
|
283
|
+
if (probed.has(key)) continue;
|
|
284
|
+
probed.add(key);
|
|
285
|
+
const hit = input.coverAt(x, y);
|
|
286
|
+
const cover = hit ? toBox(hit) : null;
|
|
287
|
+
addCover(cover && containsPoint(cover, x, y) ? cover : null);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
probeEdges(visible);
|
|
291
|
+
let trimmed = true;
|
|
292
|
+
for (let round = 0; trimmed && visible && round < MAX_TRIM_ROUNDS; round++) {
|
|
293
|
+
trimmed = false;
|
|
294
|
+
for (const cover of covers) {
|
|
295
|
+
if (bands.includes(cover) || !visible || !intersectBoxes(cover, visible)) continue;
|
|
296
|
+
if (containsBox(cover, visible)) {
|
|
297
|
+
const edge = bounds && !containsBox(cover, bounds) ? trimBand(bounds, cover) : null;
|
|
298
|
+
if (edge?.visible) {
|
|
299
|
+
bounds = edge.visible;
|
|
300
|
+
bands.push(cover);
|
|
301
|
+
}
|
|
302
|
+
return result("covered", null, { coveredBy: "element", bands });
|
|
303
|
+
}
|
|
304
|
+
const band = trimBand(visible, cover);
|
|
305
|
+
if (!band) continue;
|
|
306
|
+
visible = band.visible;
|
|
307
|
+
bounds = bounds ? cutBox(bounds, band.side, band.line) : null;
|
|
308
|
+
bands.push(cover);
|
|
309
|
+
trimmed = true;
|
|
310
|
+
}
|
|
311
|
+
if (trimmed && visible && visible.width >= MIN_VISIBLE && visible.height >= MIN_VISIBLE) {
|
|
312
|
+
probeEdges(visible);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (!visible || visible.width < MIN_VISIBLE || visible.height < MIN_VISIBLE) {
|
|
316
|
+
return result("covered", null, { coveredBy: "element", bands });
|
|
317
|
+
}
|
|
318
|
+
const inside = samples.filter((s) => containsPoint(visible, s.x, s.y));
|
|
319
|
+
const spotted = inside.filter((s) => s.cover && !bands.includes(s.cover)).length;
|
|
320
|
+
if (inside.length > 0 && spotted * 2 > inside.length) {
|
|
321
|
+
return result("covered", null, { coveredBy: "element", bands });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const cut = cutSides(rect, visible);
|
|
325
|
+
const whole = !cut.top && !cut.right && !cut.bottom && !cut.left;
|
|
326
|
+
return { state: whole ? "visible" : "partial", rect, visible, bounds, cut, bands };
|
|
327
|
+
}
|
|
328
|
+
function edgeProbes(box) {
|
|
329
|
+
const dx = Math.min(PROBE_INSET, box.width / 4);
|
|
330
|
+
const dy = Math.min(PROBE_INSET, box.height / 4);
|
|
331
|
+
const cx = box.left + box.width / 2;
|
|
332
|
+
const cy = box.top + box.height / 2;
|
|
333
|
+
return [
|
|
334
|
+
[cx, box.top + dy],
|
|
335
|
+
[box.left + box.width - dx, cy],
|
|
336
|
+
[cx, box.top + box.height - dy],
|
|
337
|
+
[box.left + dx, cy]
|
|
338
|
+
];
|
|
339
|
+
}
|
|
340
|
+
function sameBox(a, b) {
|
|
341
|
+
return a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height;
|
|
342
|
+
}
|
|
343
|
+
function cutSides(rect, visible) {
|
|
344
|
+
const half = 0.5;
|
|
345
|
+
return {
|
|
346
|
+
top: visible.top > rect.top + half,
|
|
347
|
+
left: visible.left > rect.left + half,
|
|
348
|
+
bottom: visible.top + visible.height < rect.top + rect.height - half,
|
|
349
|
+
right: visible.left + visible.width < rect.left + rect.width - half
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function cutBox(box, side, line) {
|
|
353
|
+
let { top, left } = box;
|
|
354
|
+
let bottom = box.top + box.height;
|
|
355
|
+
let right = box.left + box.width;
|
|
356
|
+
if (side === "top") top = Math.max(top, line);
|
|
357
|
+
if (side === "bottom") bottom = Math.min(bottom, line);
|
|
358
|
+
if (side === "left") left = Math.max(left, line);
|
|
359
|
+
if (side === "right") right = Math.min(right, line);
|
|
360
|
+
if (right <= left || bottom <= top) return null;
|
|
361
|
+
return { top, left, width: right - left, height: bottom - top };
|
|
362
|
+
}
|
|
363
|
+
function trimBand(visible, cover) {
|
|
364
|
+
const vRight = visible.left + visible.width;
|
|
365
|
+
const vBottom = visible.top + visible.height;
|
|
366
|
+
const cRight = cover.left + cover.width;
|
|
367
|
+
const cBottom = cover.top + cover.height;
|
|
368
|
+
const spansX = cover.left <= visible.left + EDGE && cRight >= vRight - EDGE;
|
|
369
|
+
const spansY = cover.top <= visible.top + EDGE && cBottom >= vBottom - EDGE;
|
|
370
|
+
const trim = (side, line) => ({ visible: cutBox(visible, side, line), side, line });
|
|
371
|
+
if (spansX && cover.top <= visible.top + EDGE) return trim("top", cBottom);
|
|
372
|
+
if (spansX && cBottom >= vBottom - EDGE) return trim("bottom", cover.top);
|
|
373
|
+
if (spansY && cover.left <= visible.left + EDGE) return trim("left", cRight);
|
|
374
|
+
if (spansY && cRight >= vRight - EDGE) return trim("right", cover.left);
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
201
377
|
|
|
202
378
|
// src/registry.ts
|
|
203
379
|
var TARGET_LABEL_MAX = 80;
|
|
@@ -302,6 +478,30 @@ function allTargets(root) {
|
|
|
302
478
|
return out;
|
|
303
479
|
}
|
|
304
480
|
|
|
481
|
+
// src/tabs.ts
|
|
482
|
+
var TABLIST_MENU_ATTR = "data-tablist-menu";
|
|
483
|
+
function collapsedTabMenu(tab) {
|
|
484
|
+
const list = tab.closest('[role="tablist"]');
|
|
485
|
+
if (!list || !list.hasAttribute(TABLIST_MENU_ATTR)) return void 0;
|
|
486
|
+
const id2 = (list.getAttribute(TABLIST_MENU_ATTR) ?? "").trim();
|
|
487
|
+
const menu = id2 ? tab.ownerDocument.getElementById(id2) : null;
|
|
488
|
+
if (!menu || menu === list || list.contains(menu) || menu.contains(list)) return null;
|
|
489
|
+
if (isIgnored(menu) || !isRendered(menu)) return null;
|
|
490
|
+
return menu;
|
|
491
|
+
}
|
|
492
|
+
function collapsedStandIn(el) {
|
|
493
|
+
const tab = el.closest('[role="tab"]');
|
|
494
|
+
return tab ? collapsedTabMenu(tab) ?? null : null;
|
|
495
|
+
}
|
|
496
|
+
function isTabDisabled(el) {
|
|
497
|
+
if (el.getAttribute("aria-disabled") === "true" || el.hasAttribute("disabled") || el.hasAttribute("data-disabled")) {
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
const menu = collapsedTabMenu(el);
|
|
501
|
+
if (menu !== void 0) return menu !== null && menu.closest("[inert]") !== null;
|
|
502
|
+
return el.closest("[inert]") !== null;
|
|
503
|
+
}
|
|
504
|
+
|
|
305
505
|
// src/resolve.ts
|
|
306
506
|
var TARGET_ID_MAX = 128;
|
|
307
507
|
var TARGET_TEXT_MAX = 200;
|
|
@@ -311,7 +511,7 @@ var TEXT_LENGTH_MAX = 2e3;
|
|
|
311
511
|
var SKIP_TAGS = /* @__PURE__ */ new Set(["script", "style", "template", "noscript", "head", "title", "meta"]);
|
|
312
512
|
function resolveTarget(ref, options = {}) {
|
|
313
513
|
if (!hasDom()) return { status: "not_found", message: "No page is available." };
|
|
314
|
-
if ("id" in ref) return resolveById(ref.id);
|
|
514
|
+
if ("id" in ref) return resolveById(ref.id, options);
|
|
315
515
|
let scope = document.body ?? document.documentElement;
|
|
316
516
|
if (ref.within !== void 0) {
|
|
317
517
|
const parent = resolveById(ref.within);
|
|
@@ -325,16 +525,24 @@ function resolveTarget(ref, options = {}) {
|
|
|
325
525
|
function targetIdOf(el) {
|
|
326
526
|
return getAgentTargets().find((t) => t.el === el)?.id ?? markupId(el);
|
|
327
527
|
}
|
|
328
|
-
function resolveById(id2) {
|
|
528
|
+
function resolveById(id2, options = {}) {
|
|
329
529
|
const matches = allTargets().filter((t) => t.id === id2 && !isIgnored(t.el));
|
|
330
530
|
if (matches.length === 0) {
|
|
331
531
|
return { status: "not_found", message: `No target with id "${quote(id2)}".` };
|
|
332
532
|
}
|
|
333
533
|
const visible = matches.find((t) => isRendered(t.el));
|
|
334
|
-
if (
|
|
335
|
-
|
|
534
|
+
if (visible) return { status: "ok", el: visible.el, id: visible.id, label: visible.label };
|
|
535
|
+
for (const t of matches) {
|
|
536
|
+
const shown = shownElement(t.el, options);
|
|
537
|
+
if (shown) return { status: "ok", el: shown, id: t.id, label: t.label, shownAs: "menu" };
|
|
538
|
+
}
|
|
539
|
+
return { status: "hidden", message: `Target "${quote(id2)}" is not visible right now.` };
|
|
540
|
+
}
|
|
541
|
+
function shownElement(el, options) {
|
|
542
|
+
if (options.tabsOnly) {
|
|
543
|
+
return el.getAttribute("role") === "tab" && collapsedTabMenu(el) ? el : null;
|
|
336
544
|
}
|
|
337
|
-
return
|
|
545
|
+
return collapsedStandIn(el);
|
|
338
546
|
}
|
|
339
547
|
function resolveByText(text, scope, options) {
|
|
340
548
|
const query = normalizeText(text);
|
|
@@ -364,7 +572,18 @@ function resolveByText(text, scope, options) {
|
|
|
364
572
|
};
|
|
365
573
|
}
|
|
366
574
|
const rendered = /* @__PURE__ */ new Map();
|
|
367
|
-
const
|
|
575
|
+
const shown = /* @__PURE__ */ new Map();
|
|
576
|
+
for (const el of matches) {
|
|
577
|
+
if (isRendered(el, rendered)) {
|
|
578
|
+
if (!shown.has(el)) shown.set(el, null);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const standIn = shownElement(el, options);
|
|
582
|
+
if (standIn && !shown.get(standIn)) {
|
|
583
|
+
shown.set(standIn, { tab: el.closest('[role="tab"]') ?? el });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const visible = Array.from(shown.keys());
|
|
368
587
|
if (visible.length === 0) {
|
|
369
588
|
return {
|
|
370
589
|
status: "hidden",
|
|
@@ -373,8 +592,16 @@ function resolveByText(text, scope, options) {
|
|
|
373
592
|
}
|
|
374
593
|
if (visible.length === 1) {
|
|
375
594
|
const el = visible[0];
|
|
376
|
-
const
|
|
377
|
-
|
|
595
|
+
const collapsed = shown.get(el);
|
|
596
|
+
const named = collapsed ? collapsed.tab : el;
|
|
597
|
+
const id2 = targetIdOf(named);
|
|
598
|
+
return {
|
|
599
|
+
status: "ok",
|
|
600
|
+
el,
|
|
601
|
+
...id2 ? { id: id2 } : {},
|
|
602
|
+
label: labelFor(named, id2 ?? query) || query,
|
|
603
|
+
...collapsed ? { shownAs: "menu" } : {}
|
|
604
|
+
};
|
|
378
605
|
}
|
|
379
606
|
return {
|
|
380
607
|
status: "ambiguous",
|
|
@@ -417,6 +644,103 @@ function quote(value) {
|
|
|
417
644
|
return cleanText(value, 60).replace(/"/g, "'");
|
|
418
645
|
}
|
|
419
646
|
|
|
647
|
+
// src/visibility.ts
|
|
648
|
+
var CLIPS = /^(hidden|clip|auto|scroll|overlay)$/;
|
|
649
|
+
var COVER_WALK_MAX = 16;
|
|
650
|
+
function defaultHitTest(x, y) {
|
|
651
|
+
if (typeof document.elementsFromPoint !== "function") return null;
|
|
652
|
+
for (const el of document.elementsFromPoint(x, y)) {
|
|
653
|
+
if (!el.closest(`.${LAYER_CLASS}`)) return el;
|
|
654
|
+
}
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
function hitTest(x, y) {
|
|
658
|
+
const custom = getPageAgentConfig().hitTest;
|
|
659
|
+
try {
|
|
660
|
+
return custom ? custom(x, y) : defaultHitTest(x, y);
|
|
661
|
+
} catch {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
var toBox2 = (r) => ({ top: r.top, left: r.left, width: r.width, height: r.height });
|
|
666
|
+
function clippingBoxes(el) {
|
|
667
|
+
const view = el.ownerDocument.defaultView;
|
|
668
|
+
if (!view) return [];
|
|
669
|
+
const out = [];
|
|
670
|
+
if (view.getComputedStyle(el).position === "fixed") return out;
|
|
671
|
+
const doc = el.ownerDocument;
|
|
672
|
+
for (let node = el.parentElement; node && node !== doc.body && node !== doc.documentElement; node = node.parentElement) {
|
|
673
|
+
const style = view.getComputedStyle(node);
|
|
674
|
+
if (CLIPS.test(style.overflowX) || CLIPS.test(style.overflowY) || CLIPS.test(style.overflow)) {
|
|
675
|
+
const rect = layoutRect(node);
|
|
676
|
+
if (rect) out.push(paddingBox(node, rect));
|
|
677
|
+
}
|
|
678
|
+
if (style.position === "fixed") break;
|
|
679
|
+
}
|
|
680
|
+
return out;
|
|
681
|
+
}
|
|
682
|
+
function paddingBox(node, rect) {
|
|
683
|
+
if (node.clientWidth > 0 && node.clientHeight > 0) {
|
|
684
|
+
return {
|
|
685
|
+
top: rect.top + node.clientTop,
|
|
686
|
+
left: rect.left + node.clientLeft,
|
|
687
|
+
width: Math.min(node.clientWidth, rect.width),
|
|
688
|
+
height: Math.min(node.clientHeight, rect.height)
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
return toBox2(rect);
|
|
692
|
+
}
|
|
693
|
+
function isModalDialog(el) {
|
|
694
|
+
if (el.getAttribute("aria-modal") === "true") return true;
|
|
695
|
+
if (el.tagName === "DIALOG") {
|
|
696
|
+
try {
|
|
697
|
+
return el.matches(":modal");
|
|
698
|
+
} catch {
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
function isBehindModal(el) {
|
|
705
|
+
if (el.closest("[inert]")) return true;
|
|
706
|
+
const doc = el.ownerDocument;
|
|
707
|
+
const modals = Array.from(doc.querySelectorAll('[aria-modal="true"], dialog')).filter(
|
|
708
|
+
(m) => isModalDialog(m) && !m.closest(`.${LAYER_CLASS}`) && isRendered(m)
|
|
709
|
+
);
|
|
710
|
+
const top = modals[modals.length - 1];
|
|
711
|
+
return !!top && !top.contains(el);
|
|
712
|
+
}
|
|
713
|
+
function coverBox(el, x, y) {
|
|
714
|
+
const hit = hitTest(x, y);
|
|
715
|
+
if (!hit || hit === el || el.contains(hit) || hit.contains(el)) return null;
|
|
716
|
+
let box = layoutRect(hit);
|
|
717
|
+
if (!box) return null;
|
|
718
|
+
let node = hit.parentElement;
|
|
719
|
+
for (let i = 0; node && i < COVER_WALK_MAX && !node.contains(el); i++) {
|
|
720
|
+
const outer = layoutRect(node);
|
|
721
|
+
if (!outer || !encloses(outer, box)) break;
|
|
722
|
+
box = outer;
|
|
723
|
+
node = node.parentElement;
|
|
724
|
+
}
|
|
725
|
+
return box;
|
|
726
|
+
}
|
|
727
|
+
function encloses(outer, inner) {
|
|
728
|
+
return outer.top <= inner.top + 1 && outer.left <= inner.left + 1 && outer.bottom >= inner.bottom - 1 && outer.right >= inner.right - 1;
|
|
729
|
+
}
|
|
730
|
+
function measureVisibility(el) {
|
|
731
|
+
const rect = hasDom() && el.isConnected ? layoutRect(el) : null;
|
|
732
|
+
if (!rect) {
|
|
733
|
+
return computeVisibility({ rect: null, viewport: viewportSize() });
|
|
734
|
+
}
|
|
735
|
+
return computeVisibility({
|
|
736
|
+
rect,
|
|
737
|
+
viewport: viewportSize(),
|
|
738
|
+
clips: clippingBoxes(el),
|
|
739
|
+
behindModal: isBehindModal(el),
|
|
740
|
+
coverAt: (x, y) => coverBox(el, x, y)
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
420
744
|
// src/controller.ts
|
|
421
745
|
var EFFECT_DEFAULT_MS = 8e3;
|
|
422
746
|
var EFFECT_MIN_MS = 1e3;
|
|
@@ -496,12 +820,22 @@ function add(kind, resolved, label2, durationMs2) {
|
|
|
496
820
|
function highlight(ref, options = {}) {
|
|
497
821
|
const resolved = target(ref);
|
|
498
822
|
if (resolved.status !== "ok") return resolved;
|
|
499
|
-
|
|
500
|
-
return { status: "ok", effect: add(kind, resolved, options.label, options.durationMs) };
|
|
823
|
+
return highlightResolved(resolved, options);
|
|
501
824
|
}
|
|
502
825
|
function point(ref, options = {}) {
|
|
503
826
|
const resolved = target(ref);
|
|
504
827
|
if (resolved.status !== "ok") return resolved;
|
|
828
|
+
return pointResolved(resolved, options);
|
|
829
|
+
}
|
|
830
|
+
function highlightResolved(resolved, options = {}) {
|
|
831
|
+
if (!resolved.el.isConnected)
|
|
832
|
+
return { status: "not_found", message: "Element is not available." };
|
|
833
|
+
const kind = options.style === "spotlight" ? "spotlight" : "outline";
|
|
834
|
+
return { status: "ok", effect: add(kind, resolved, options.label, options.durationMs) };
|
|
835
|
+
}
|
|
836
|
+
function pointResolved(resolved, options = {}) {
|
|
837
|
+
if (!resolved.el.isConnected)
|
|
838
|
+
return { status: "not_found", message: "Element is not available." };
|
|
505
839
|
return { status: "ok", effect: add("pointer", resolved, options.label, options.durationMs) };
|
|
506
840
|
}
|
|
507
841
|
function scrollToTarget(ref) {
|
|
@@ -531,6 +865,9 @@ function onEffectsChange(listener) {
|
|
|
531
865
|
function rectOf(effect) {
|
|
532
866
|
return layoutRect(effect.el);
|
|
533
867
|
}
|
|
868
|
+
function visibilityOf(effect) {
|
|
869
|
+
return measureVisibility(effect.el);
|
|
870
|
+
}
|
|
534
871
|
function notifyNavigated() {
|
|
535
872
|
clearEffects();
|
|
536
873
|
}
|
|
@@ -606,6 +943,34 @@ function scrollIntoContainers(el) {
|
|
|
606
943
|
}
|
|
607
944
|
return scrolled;
|
|
608
945
|
}
|
|
946
|
+
function nudgeIntoBounds(el, visibility) {
|
|
947
|
+
if (typeof window === "undefined") return false;
|
|
948
|
+
const { rect, bounds } = visibility;
|
|
949
|
+
if (!rect || !bounds) return false;
|
|
950
|
+
const margin = 8;
|
|
951
|
+
const top = bounds.top + margin;
|
|
952
|
+
const room = bounds.height - margin * 2;
|
|
953
|
+
if (room <= 0) return false;
|
|
954
|
+
const bottom = rect.top + rect.height;
|
|
955
|
+
if (rect.top >= bounds.top && bottom <= bounds.top + bounds.height) return false;
|
|
956
|
+
const want = rect.height <= room ? rect.top - (top + (room - rect.height) / 2) : rect.top - top;
|
|
957
|
+
const view = el.ownerDocument.defaultView ?? window;
|
|
958
|
+
if (view.getComputedStyle(el).position === "fixed") return false;
|
|
959
|
+
const behavior = prefersReducedMotion() ? "auto" : "smooth";
|
|
960
|
+
for (let node = el.parentElement; node && node !== document.body && node !== document.documentElement; node = node.parentElement) {
|
|
961
|
+
const style = view.getComputedStyle(node);
|
|
962
|
+
if (style.position === "fixed") return false;
|
|
963
|
+
if (!isScrollable(node, style).y) continue;
|
|
964
|
+
const dy2 = clampScroll(node.scrollTop, want, node.scrollHeight - node.clientHeight);
|
|
965
|
+
if (!dy2) return false;
|
|
966
|
+
scrollBy(node, 0, dy2, behavior);
|
|
967
|
+
return true;
|
|
968
|
+
}
|
|
969
|
+
const dy = Math.round(Math.max(want, -(view.scrollY || 0)));
|
|
970
|
+
if (!dy) return false;
|
|
971
|
+
scrollBy(view, 0, dy, behavior);
|
|
972
|
+
return true;
|
|
973
|
+
}
|
|
609
974
|
function clampScroll(current, want, max) {
|
|
610
975
|
const next = Math.min(Math.max(current + want, 0), Math.max(max, 0));
|
|
611
976
|
return Math.round(next - current);
|
|
@@ -650,9 +1015,6 @@ function collectTargets(options = {}) {
|
|
|
650
1015
|
out.sort((a, b) => Number(b.visible) - Number(a.visible));
|
|
651
1016
|
return { targets: out.slice(0, limit), total: out.length };
|
|
652
1017
|
}
|
|
653
|
-
function isTabDisabled(el) {
|
|
654
|
-
return el.getAttribute("aria-disabled") === "true" || el.hasAttribute("disabled") || el.hasAttribute("data-disabled") || el.closest("[inert]") !== null;
|
|
655
|
-
}
|
|
656
1018
|
function listTabs(options = {}) {
|
|
657
1019
|
if (!hasDom()) return [];
|
|
658
1020
|
const limit = clampLimit(options.limit, LIST_MAX);
|
|
@@ -662,12 +1024,14 @@ function listTabs(options = {}) {
|
|
|
662
1024
|
if (out.length >= limit) break;
|
|
663
1025
|
if (isIgnored(el)) continue;
|
|
664
1026
|
const id2 = targetIdOf(el);
|
|
1027
|
+
const menu = collapsedTabMenu(el);
|
|
665
1028
|
out.push({
|
|
666
1029
|
...id2 ? { id: id2 } : {},
|
|
667
1030
|
label: labelFor(el, id2 ?? "") || "Tab",
|
|
668
1031
|
selected: el.getAttribute("aria-selected") === "true",
|
|
669
1032
|
disabled: isTabDisabled(el),
|
|
670
|
-
visible: isRendered(el, rendered)
|
|
1033
|
+
visible: menu === void 0 ? isRendered(el, rendered) : menu !== null,
|
|
1034
|
+
...menu !== void 0 ? { shownAs: "menu" } : {}
|
|
671
1035
|
});
|
|
672
1036
|
}
|
|
673
1037
|
return out;
|
|
@@ -966,12 +1330,50 @@ function failure(result) {
|
|
|
966
1330
|
function summary(resolved) {
|
|
967
1331
|
return { ...resolved.id ? { id: resolved.id } : {}, label: resolved.label };
|
|
968
1332
|
}
|
|
969
|
-
|
|
970
|
-
if (scrolled) return true;
|
|
971
|
-
const rect = layoutRect(el);
|
|
972
|
-
return !!rect && intersectsViewport(rect, viewportSize());
|
|
973
|
-
}
|
|
1333
|
+
var MENU_NOTE = "The tabs are shown as a drop-down menu here (the page is narrow), not as a row of tabs.";
|
|
974
1334
|
var wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1335
|
+
var SCROLL_SETTLE_MAX_MS = 1e3;
|
|
1336
|
+
var SCROLL_SETTLE_STEP_MS = 50;
|
|
1337
|
+
async function settleLayout(el) {
|
|
1338
|
+
const key = () => {
|
|
1339
|
+
const r = layoutRect(el);
|
|
1340
|
+
return r ? `${Math.round(r.top)},${Math.round(r.left)}` : "";
|
|
1341
|
+
};
|
|
1342
|
+
let last = key();
|
|
1343
|
+
let stable = 0;
|
|
1344
|
+
for (let waited = 0; waited < SCROLL_SETTLE_MAX_MS; waited += SCROLL_SETTLE_STEP_MS) {
|
|
1345
|
+
await wait(SCROLL_SETTLE_STEP_MS);
|
|
1346
|
+
const next = key();
|
|
1347
|
+
if (next === last) {
|
|
1348
|
+
if (++stable >= 2) return;
|
|
1349
|
+
} else {
|
|
1350
|
+
stable = 0;
|
|
1351
|
+
last = next;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
async function reveal(el, scrolledAlready) {
|
|
1356
|
+
let scrolled = scrolledAlready ?? scrollIntoContainers(el);
|
|
1357
|
+
if (scrolled) await settleLayout(el);
|
|
1358
|
+
let visibility = measureVisibility(el);
|
|
1359
|
+
if (visibility.bands.length > 0 && nudgeIntoBounds(el, visibility)) {
|
|
1360
|
+
scrolled = true;
|
|
1361
|
+
await settleLayout(el);
|
|
1362
|
+
visibility = measureVisibility(el);
|
|
1363
|
+
}
|
|
1364
|
+
return { scrolled, visibility };
|
|
1365
|
+
}
|
|
1366
|
+
var onScreen = (v) => v.state === "visible" || v.state === "partial";
|
|
1367
|
+
function coveredResult(visibility, label2) {
|
|
1368
|
+
if (visibility.state !== "covered") return null;
|
|
1369
|
+
const what = `"${label2.replace(/"/g, "'")}"`;
|
|
1370
|
+
const modal = visibility.coveredBy === "modal";
|
|
1371
|
+
return {
|
|
1372
|
+
status: "hidden",
|
|
1373
|
+
message: modal ? `${what} is behind a dialog right now, so it cannot be shown. Point at something in the dialog, or ask the user to close it.` : `${what} is covered by another part of the page (such as a header, panel or popup), so it cannot be shown right now.`,
|
|
1374
|
+
output: { reason: modal ? "behind_dialog" : "covered" }
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
975
1377
|
async function confirmSelected(el, settleMs) {
|
|
976
1378
|
const selected = () => el.isConnected && el.getAttribute("aria-selected") === "true";
|
|
977
1379
|
await Promise.resolve();
|
|
@@ -1005,7 +1407,7 @@ var handlers = {
|
|
|
1005
1407
|
};
|
|
1006
1408
|
return { status: "ok", output };
|
|
1007
1409
|
},
|
|
1008
|
-
highlight(raw) {
|
|
1410
|
+
async highlight(raw) {
|
|
1009
1411
|
const input = object(raw, "input", ["target", "style", "label", "durationMs"]);
|
|
1010
1412
|
const target2 = targetRef2(input.target);
|
|
1011
1413
|
let style = "outline";
|
|
@@ -1018,54 +1420,78 @@ var handlers = {
|
|
|
1018
1420
|
const durationMs2 = optionalDuration(input.durationMs);
|
|
1019
1421
|
const resolved = resolveTarget(target2);
|
|
1020
1422
|
if (resolved.status !== "ok") return failure(resolved);
|
|
1021
|
-
const scrolled =
|
|
1022
|
-
const
|
|
1423
|
+
const { scrolled, visibility } = await reveal(resolved.el);
|
|
1424
|
+
const covered = coveredResult(visibility, resolved.label);
|
|
1425
|
+
if (covered) return covered;
|
|
1426
|
+
if (!isRendered(resolved.el)) {
|
|
1427
|
+
return { status: "hidden", message: "That target is no longer visible." };
|
|
1428
|
+
}
|
|
1429
|
+
const outcome = highlightResolved(resolved, { style, label: label2, durationMs: durationMs2 });
|
|
1023
1430
|
if (outcome.status !== "ok") return failure(outcome);
|
|
1024
1431
|
if (!getEffects().some((e) => e.id === outcome.effect.id)) {
|
|
1025
1432
|
return { status: "error", message: "The highlight could not be shown." };
|
|
1026
1433
|
}
|
|
1027
1434
|
return {
|
|
1028
1435
|
status: "ok",
|
|
1436
|
+
...resolved.shownAs ? { message: `${MENU_NOTE} Highlighted that menu.` } : {},
|
|
1029
1437
|
output: {
|
|
1030
1438
|
effectId: outcome.effect.id,
|
|
1031
1439
|
target: summary(resolved),
|
|
1032
1440
|
style,
|
|
1033
1441
|
scrolled,
|
|
1034
|
-
inViewport:
|
|
1442
|
+
inViewport: onScreen(visibility),
|
|
1443
|
+
...resolved.shownAs ? { shownAs: resolved.shownAs } : {}
|
|
1035
1444
|
}
|
|
1036
1445
|
};
|
|
1037
1446
|
},
|
|
1038
|
-
point(raw) {
|
|
1447
|
+
async point(raw) {
|
|
1039
1448
|
const input = object(raw, "input", ["target", "label", "durationMs"]);
|
|
1040
1449
|
const target2 = targetRef2(input.target);
|
|
1041
1450
|
const label2 = optionalLabel(input.label);
|
|
1042
1451
|
const durationMs2 = optionalDuration(input.durationMs);
|
|
1043
1452
|
const resolved = resolveTarget(target2);
|
|
1044
1453
|
if (resolved.status !== "ok") return failure(resolved);
|
|
1045
|
-
const scrolled =
|
|
1046
|
-
const
|
|
1454
|
+
const { scrolled, visibility } = await reveal(resolved.el);
|
|
1455
|
+
const covered = coveredResult(visibility, resolved.label);
|
|
1456
|
+
if (covered) return covered;
|
|
1457
|
+
if (!isRendered(resolved.el)) {
|
|
1458
|
+
return { status: "hidden", message: "That target is no longer visible." };
|
|
1459
|
+
}
|
|
1460
|
+
const outcome = pointResolved(resolved, { label: label2, durationMs: durationMs2 });
|
|
1047
1461
|
if (outcome.status !== "ok") return failure(outcome);
|
|
1048
1462
|
if (!getEffects().some((e) => e.id === outcome.effect.id)) {
|
|
1049
1463
|
return { status: "error", message: "The pointer could not be shown." };
|
|
1050
1464
|
}
|
|
1051
1465
|
return {
|
|
1052
1466
|
status: "ok",
|
|
1467
|
+
...resolved.shownAs ? { message: `${MENU_NOTE} Pointed at that menu.` } : {},
|
|
1053
1468
|
output: {
|
|
1054
1469
|
effectId: outcome.effect.id,
|
|
1055
1470
|
target: summary(resolved),
|
|
1056
1471
|
scrolled,
|
|
1057
|
-
inViewport:
|
|
1472
|
+
inViewport: onScreen(visibility),
|
|
1473
|
+
...resolved.shownAs ? { shownAs: resolved.shownAs } : {}
|
|
1058
1474
|
}
|
|
1059
1475
|
};
|
|
1060
1476
|
},
|
|
1061
|
-
scroll_to(raw) {
|
|
1477
|
+
async scroll_to(raw) {
|
|
1062
1478
|
const input = object(raw, "input", ["target"]);
|
|
1063
1479
|
const target2 = targetRef2(input.target);
|
|
1064
1480
|
const resolved = resolveTarget(target2);
|
|
1065
1481
|
if (resolved.status !== "ok") return failure(resolved);
|
|
1066
1482
|
const outcome = scrollToTarget(resolved.el);
|
|
1067
1483
|
if (outcome.status !== "ok") return failure(outcome);
|
|
1068
|
-
|
|
1484
|
+
const { scrolled, visibility } = await reveal(resolved.el, outcome.scrolled);
|
|
1485
|
+
return {
|
|
1486
|
+
status: "ok",
|
|
1487
|
+
output: {
|
|
1488
|
+
target: summary(resolved),
|
|
1489
|
+
scrolled,
|
|
1490
|
+
inViewport: onScreen(visibility),
|
|
1491
|
+
...visibility.state === "covered" ? { covered: true } : {},
|
|
1492
|
+
...resolved.shownAs ? { shownAs: resolved.shownAs } : {}
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1069
1495
|
},
|
|
1070
1496
|
clear_highlights(raw) {
|
|
1071
1497
|
optionalInput(raw, []);
|
|
@@ -1085,15 +1511,30 @@ var handlers = {
|
|
|
1085
1511
|
if (!(el instanceof HTMLElement))
|
|
1086
1512
|
return { status: "refused", message: "That tab cannot be activated." };
|
|
1087
1513
|
if (isTabDisabled(el)) return { status: "refused", message: "That tab is disabled." };
|
|
1514
|
+
const menu = collapsedTabMenu(el);
|
|
1515
|
+
const asMenu = menu !== void 0;
|
|
1516
|
+
const extra = asMenu ? { shownAs: "menu" } : {};
|
|
1088
1517
|
if (el.getAttribute("aria-selected") === "true") {
|
|
1089
|
-
return {
|
|
1518
|
+
return {
|
|
1519
|
+
status: "ok",
|
|
1520
|
+
...asMenu ? { message: MENU_NOTE } : {},
|
|
1521
|
+
output: { target: summary(resolved), alreadySelected: true, ...extra }
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
const shown = asMenu ? menu : isRendered(el) ? el : null;
|
|
1525
|
+
if (!shown) return { status: "hidden", message: "That tab is not visible right now." };
|
|
1526
|
+
if (isBehindModal(shown)) {
|
|
1527
|
+
return { status: "hidden", message: "That tab is behind a dialog right now." };
|
|
1090
1528
|
}
|
|
1091
|
-
if (!isRendered(el)) return { status: "hidden", message: "That tab is not visible right now." };
|
|
1092
1529
|
el.click();
|
|
1093
1530
|
if (!await confirmSelected(el, getPageAgentConfig().tabSettleMs)) {
|
|
1094
1531
|
return { status: "error", message: "The tab did not become selected." };
|
|
1095
1532
|
}
|
|
1096
|
-
return {
|
|
1533
|
+
return {
|
|
1534
|
+
status: "ok",
|
|
1535
|
+
...asMenu ? { message: `${MENU_NOTE} The menu now shows this tab.` } : {},
|
|
1536
|
+
output: { target: summary(resolved), alreadySelected: false, ...extra }
|
|
1537
|
+
};
|
|
1097
1538
|
},
|
|
1098
1539
|
async navigate(raw) {
|
|
1099
1540
|
const input = object(raw, "input", ["path"]);
|
|
@@ -1133,4 +1574,4 @@ function resetPageAgent() {
|
|
|
1133
1574
|
resetPageAgentConfig();
|
|
1134
1575
|
}
|
|
1135
1576
|
|
|
1136
|
-
export { CALL_LABEL_MAX, CANDIDATES_MAX, EFFECTS_MAX, EFFECT_DEFAULT_MS, EFFECT_LABEL_MAX, EFFECT_MAX_MS, EFFECT_MIN_MS, LAYER_CLASS, LIST_MAX, NAVIGATE_PATH_MAX, PAGE_TEXT_DEFAULT, PAGE_TEXT_MAX, PAGE_TOOLS, PAGE_TOOL_NAMES, TARGET_ID_MAX, TARGET_LABEL_MAX, TARGET_TEXT_MAX, TRUNCATION_MARKER, attachPageAgent, clampDuration, clampToViewport, clearEffects, configurePageAgent, executePageCall, getAgentTargets, getEffects, getPageAgentConfig, highlight, inflate, intersectsViewport, isFullyInViewport, isRendered, listTabs, listTargets, notifyNavigated, onAgentTargetsChange, onEffectsChange, parseAppPath, placeLabel, point, pointerTarget, prefersReducedMotion, readPageText, rectOf, registerAgentTarget, resetPageAgent, resolveTarget, scanAgentAttributes, scrollIntoContainers, scrollToTarget, targetIdOf, unionRects, viewportSize };
|
|
1577
|
+
export { CALL_LABEL_MAX, CANDIDATES_MAX, EFFECTS_MAX, EFFECT_DEFAULT_MS, EFFECT_LABEL_MAX, EFFECT_MAX_MS, EFFECT_MIN_MS, LAYER_CLASS, LIST_MAX, NAVIGATE_PATH_MAX, PAGE_TEXT_DEFAULT, PAGE_TEXT_MAX, PAGE_TOOLS, PAGE_TOOL_NAMES, TABLIST_MENU_ATTR, TARGET_ID_MAX, TARGET_LABEL_MAX, TARGET_TEXT_MAX, TRUNCATION_MARKER, attachPageAgent, clampDuration, clampToViewport, clearEffects, clippingBoxes, collapsedTabMenu, computeVisibility, configurePageAgent, executePageCall, getAgentTargets, getEffects, getPageAgentConfig, highlight, inflate, intersectBoxes, intersectsViewport, isBehindModal, isFullyInViewport, isRendered, listTabs, listTargets, measureVisibility, notifyNavigated, nudgeIntoBounds, onAgentTargetsChange, onEffectsChange, parseAppPath, placeLabel, point, pointerTarget, prefersReducedMotion, readPageText, rectOf, registerAgentTarget, resetPageAgent, resolveTarget, scanAgentAttributes, scrollIntoContainers, scrollToTarget, targetIdOf, unionRects, viewportSize, visibilityOf };
|
package/dist/styles.css
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
*
|
|
8
8
|
* <div class="chpa"> root; never a target
|
|
9
9
|
* <div class="chpa-layer" aria-hidden="true"> fixed, click-through
|
|
10
|
-
* <div class="chpa-outline" style="top/left/width/height"
|
|
11
|
-
*
|
|
10
|
+
* <div class="chpa-outline" style="top/left/width/height[; clip-path]"
|
|
11
|
+
* data-cut="top right bottom left (the sides cut off, if any)">
|
|
12
|
+
* <span class="chpa-tag" data-placement="above|below|inside">caption</span>
|
|
12
13
|
* </div>
|
|
13
14
|
* <div class="chpa-spotlight" style="top/left/width/height"></div>
|
|
14
15
|
* <div class="chpa-pointer" style="transform: translate(x, y)">
|
|
@@ -29,6 +30,11 @@
|
|
|
29
30
|
* back to the classic-theme HSL tokens. Dark mode follows `.dark` or
|
|
30
31
|
* `[data-theme='dark']` on an ancestor.
|
|
31
32
|
*
|
|
33
|
+
* The layer sits above everything (so targets inside dialogs can be
|
|
34
|
+
* pointed at); the bindings draw only the part of a target the user can
|
|
35
|
+
* see, so it never paints over a sticky header, dock or dialog that covers
|
|
36
|
+
* the target (see `measureVisibility`).
|
|
37
|
+
*
|
|
32
38
|
* Motion only under `prefers-reduced-motion: no-preference` (pointer glide,
|
|
33
39
|
* spotlight pulse). Hover never transitions colour.
|
|
34
40
|
*/
|
|
@@ -116,6 +122,40 @@
|
|
|
116
122
|
bottom: auto;
|
|
117
123
|
}
|
|
118
124
|
|
|
125
|
+
.chpa-tag[data-placement='inside'] {
|
|
126
|
+
top: 4px;
|
|
127
|
+
bottom: auto;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/*
|
|
131
|
+
* A side of the target is cut off (it continues under a sticky header, past
|
|
132
|
+
* a scroll box's edge): leave that side open, square, so the outline reads
|
|
133
|
+
* as running on under it. The binding also clips the ring there.
|
|
134
|
+
*/
|
|
135
|
+
.chpa-outline[data-cut~='top'] {
|
|
136
|
+
border-top-color: transparent;
|
|
137
|
+
border-top-left-radius: 0;
|
|
138
|
+
border-top-right-radius: 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.chpa-outline[data-cut~='bottom'] {
|
|
142
|
+
border-bottom-color: transparent;
|
|
143
|
+
border-bottom-left-radius: 0;
|
|
144
|
+
border-bottom-right-radius: 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.chpa-outline[data-cut~='left'] {
|
|
148
|
+
border-left-color: transparent;
|
|
149
|
+
border-top-left-radius: 0;
|
|
150
|
+
border-bottom-left-radius: 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.chpa-outline[data-cut~='right'] {
|
|
154
|
+
border-right-color: transparent;
|
|
155
|
+
border-top-right-radius: 0;
|
|
156
|
+
border-bottom-right-radius: 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
119
159
|
/*
|
|
120
160
|
* Spotlight: a clear hole over the target with the rest of the page dimmed
|
|
121
161
|
* by a huge spread shadow. Purely visual; `pointer-events: none` keeps the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classic-homes/page-agent-core",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Framework-agnostic page layer for an in-app AI agent: named targets, safe target resolution, a page snapshot, highlight / spotlight / pointer effects and one schema-validated executor for model tool calls",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "dist/index.js",
|