@classic-homes/page-agent-core 0.1.0 → 0.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.
- package/README.md +51 -8
- package/dist/index.d.ts +187 -44
- package/dist/index.js +447 -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,17 @@ 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 sticky
|
|
51
|
+
header / footer / dock covers it (hit-tested with
|
|
52
|
+
`document.elementsFromPoint`, the layer skipped), and `covered` behind a
|
|
53
|
+
modal dialog or `inert`. Bindings draw only `visible` and hide (not
|
|
54
|
+
remove) effects while it is null. The maths is the pure
|
|
55
|
+
`computeVisibility({ rect, viewport, clips, behindModal, coverAt })`.
|
|
56
|
+
- **Collapsed tab rows**: `collapsedTabMenu(tab)` and the
|
|
57
|
+
`data-tablist-menu` DOM contract (below).
|
|
58
|
+
- **Geometry**: `unionRects`, `inflate`, `clampToViewport`, `intersectBoxes`,
|
|
59
|
+
`placeLabel` (with optional `bounds`), `pointerTarget` for the UI layer.
|
|
50
60
|
|
|
51
61
|
```ts
|
|
52
62
|
import {
|
|
@@ -78,7 +88,7 @@ const result = await executePageCall({
|
|
|
78
88
|
| ------------------ | ----------------------------------------- | --------------------------------------------------------- |
|
|
79
89
|
| `list_targets` | `{ kind?, limit? }` | Named targets and tabs on the page |
|
|
80
90
|
| `highlight` | `{ target, style?, label?, durationMs? }` | Outline or spotlight, scrolling into view first if needed |
|
|
81
|
-
| `point` | `{ target, label?, durationMs? }` | Moves the ghost cursor
|
|
91
|
+
| `point` | `{ target, label?, durationMs? }` | Moves the ghost cursor, scrolling into view if needed |
|
|
82
92
|
| `scroll_to` | `{ target }` | Scrolls into view (no-op if already fully visible) |
|
|
83
93
|
| `clear_highlights` | `{}` | Removes every effect |
|
|
84
94
|
| `select_tab` | `{ target }` | Clicks a `role="tab"`, `ok` once `aria-selected="true"` |
|
|
@@ -88,6 +98,33 @@ const result = await executePageCall({
|
|
|
88
98
|
output? }` with `status` one of `ok`, `not_found`, `ambiguous`, `hidden`,
|
|
89
99
|
`refused`, `error`.
|
|
90
100
|
|
|
101
|
+
`highlight`, `point` and `scroll_to` wait for their scroll to settle, scroll
|
|
102
|
+
once more if a sticky header or footer still covers the target, and measure
|
|
103
|
+
`inViewport` after that. `highlight` / `point` on a target the user cannot
|
|
104
|
+
see because something covers it return `hidden` with `output.reason`
|
|
105
|
+
`'behind_dialog'` or `'covered'`, and show nothing.
|
|
106
|
+
|
|
107
|
+
## Collapsed tab rows
|
|
108
|
+
|
|
109
|
+
A tab widget that swaps its row for a drop-down when it does not fit (theme
|
|
110
|
+
`Tabs`, `collapse="auto"`) keeps the `role="tab"` buttons in the DOM,
|
|
111
|
+
invisible and `inert`, and marks the row while collapsed:
|
|
112
|
+
|
|
113
|
+
```html
|
|
114
|
+
<div role="tablist" data-tablist-menu="rec-menu">
|
|
115
|
+
<button role="tab" aria-selected="false">Documents</button>
|
|
116
|
+
</div>
|
|
117
|
+
<button id="rec-menu">Overview ▾</button>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`select_tab` then still clicks the hidden tab (browsers run a script
|
|
121
|
+
`click()` on inert elements), confirms `aria-selected="true"` and answers
|
|
122
|
+
with `output.shownAs: 'menu'` and a `message` that the tabs are a drop-down.
|
|
123
|
+
`highlight` / `point` on such a tab go to the visible stand-in (the
|
|
124
|
+
attribute's id). `listTabs()` reports them `visible` (while the stand-in is)
|
|
125
|
+
with `shownAs: 'menu'`. A tab is never treated as collapsed without the
|
|
126
|
+
attribute, and nothing but `role="tab"` is ever clicked.
|
|
127
|
+
|
|
91
128
|
## Security
|
|
92
129
|
|
|
93
130
|
`executePageCall` treats its input as untrusted:
|
|
@@ -96,12 +133,14 @@ output? }` with `status` one of `ok`, `not_found`, `ambiguous`, `hidden`,
|
|
|
96
133
|
unknown keys and malformed input return `refused`. It never throws.
|
|
97
134
|
- Targets are ids (compared verbatim with attribute values) or visible
|
|
98
135
|
text. No CSS selectors, HTML or code are accepted.
|
|
99
|
-
- Nothing is clicked except a non-disabled `role="tab"` element
|
|
136
|
+
- Nothing is clicked except a non-disabled `role="tab"` element (also in a
|
|
137
|
+
collapsed row; never its drop-down or any `<select>`).
|
|
100
138
|
- `navigate` accepts only paths starting with a single `/` (no `//`,
|
|
101
139
|
backslashes, schemes, whitespace or control characters, also after
|
|
102
140
|
decoding) and goes through the host's handler; without one it is refused.
|
|
103
141
|
- `ok` is reported only once the effect is in place (the effect is in the
|
|
104
|
-
store, the tab is selected, the host's
|
|
142
|
+
store and its target is not covered, the tab is selected, the host's
|
|
143
|
+
navigation resolved).
|
|
105
144
|
- `[data-agent-ignore]`, the host `ignoreSelector` and the layer's own UI
|
|
106
145
|
(`.chpa`) are invisible to resolution, snapshots and `readPageText`, which
|
|
107
146
|
also never reads password, hidden, card or one-time-code field values.
|
|
@@ -117,11 +156,15 @@ keeps the page clickable), the ghost cursor (`.chpa-pointer`,
|
|
|
117
156
|
Dismiss" chip (`.chpa-chip`, `.chpa-chip-text`, `.chpa-chip-btn`, 44 px
|
|
118
157
|
target) and a live region (`.chpa-sr`). Override `--chpa-accent` (default
|
|
119
158
|
`#6d28d9`) and `--chpa-z` (default `2147482000`, below the Access
|
|
120
|
-
Inspector).
|
|
159
|
+
Inspector). The layer stays on top so targets inside dialogs can be pointed
|
|
160
|
+
at; covers are handled by drawing only the visible part (an outline cut by
|
|
161
|
+
a header carries `data-cut="top"` and is left open on that side). Dark mode follows `.dark` / `[data-theme='dark']`; reduced
|
|
121
162
|
motion drops the glide and pulse.
|
|
122
163
|
|
|
123
164
|
## Testing
|
|
124
165
|
|
|
125
166
|
jsdom has no layout, so pass `configurePageAgent({ measure })` to supply
|
|
126
167
|
element boxes. The style rules (`hidden`, `display: none`, `visibility`,
|
|
127
|
-
`opacity: 0`) still run for real.
|
|
168
|
+
`opacity: 0`) still run for real. jsdom has no hit testing either: nothing
|
|
169
|
+
counts as covered unless you pass `configurePageAgent({ hitTest: (x, y) =>
|
|
170
|
+
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,123 @@ 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 band). */
|
|
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 edge bands (sticky header, footer, side dock) that
|
|
293
|
+
* cover it. Captions are kept inside it. Null when there is none.
|
|
294
|
+
*/
|
|
295
|
+
bounds: Box | null;
|
|
296
|
+
cut: CutSides;
|
|
297
|
+
/** With `covered`: a modal dialog / inert page, or another element on top. */
|
|
298
|
+
coveredBy?: 'modal' | 'element';
|
|
299
|
+
/** Edge bands that cover part (or all) of the target, as measured. */
|
|
300
|
+
bands: Box[];
|
|
301
|
+
}
|
|
302
|
+
interface VisibilityInput {
|
|
303
|
+
/** The target's layout box; null when it has none. */
|
|
304
|
+
rect: RectLike | null;
|
|
305
|
+
viewport: Size;
|
|
306
|
+
/** Boxes of clipping ancestors (scroll boxes, `overflow: hidden`), innermost first. */
|
|
307
|
+
clips?: readonly RectLike[];
|
|
308
|
+
/** The target is behind a modal dialog or inside an `inert` subtree. */
|
|
309
|
+
behindModal?: boolean;
|
|
310
|
+
/**
|
|
311
|
+
* Hit test: the box of whatever is drawn on top of the target at this
|
|
312
|
+
* viewport point, or null when the target itself (or nothing) is.
|
|
313
|
+
*/
|
|
314
|
+
coverAt?: (x: number, y: number) => RectLike | null;
|
|
315
|
+
/** Sample grid per side (default 3, so 9 points). */
|
|
316
|
+
grid?: number;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Where the user can actually see a target: its box intersected with the
|
|
320
|
+
* viewport and every clipping ancestor, then hit-tested at a grid of points.
|
|
321
|
+
*
|
|
322
|
+
* Occlusion rules, chosen to be robust with coarse sampling:
|
|
323
|
+
* - something that covers the whole visible part (a dialog backdrop) hides it;
|
|
324
|
+
* - an edge band (a box spanning the visible part's full width or height and
|
|
325
|
+
* reaching past one edge, like a sticky header) is cut off exactly at the
|
|
326
|
+
* band's edge, so the effect stops where the header starts;
|
|
327
|
+
* - anything else on top (a floating button, a small popover) hides the
|
|
328
|
+
* target only when it covers most of the remaining sample points, and is
|
|
329
|
+
* otherwise drawn over: clipping around arbitrary shapes is not worth the
|
|
330
|
+
* wrong guesses.
|
|
331
|
+
*/
|
|
332
|
+
declare function computeVisibility(input: VisibilityInput): TargetVisibility;
|
|
333
|
+
|
|
198
334
|
declare const EFFECT_DEFAULT_MS = 8000;
|
|
199
335
|
declare const EFFECT_MIN_MS = 1000;
|
|
200
336
|
declare const EFFECT_MAX_MS = 60000;
|
|
@@ -235,6 +371,13 @@ declare function getEffects(): readonly PageEffect[];
|
|
|
235
371
|
declare function onEffectsChange(listener: () => void): () => void;
|
|
236
372
|
/** Viewport rect of an effect's element, or null when it has no layout box (skip drawing). */
|
|
237
373
|
declare function rectOf(effect: PageEffect): DOMRect | null;
|
|
374
|
+
/**
|
|
375
|
+
* What of an effect's element the user can see right now (clipped by
|
|
376
|
+
* scroll boxes, cut under sticky headers, hidden behind dialogs). Bindings
|
|
377
|
+
* draw `visible` and hide the effect, without removing it, while it is
|
|
378
|
+
* null. See `measureVisibility`.
|
|
379
|
+
*/
|
|
380
|
+
declare function visibilityOf(effect: PageEffect): TargetVisibility;
|
|
238
381
|
/** Tell the agent layer the route changed (SPA navigation): clears every effect. */
|
|
239
382
|
declare function notifyNavigated(): void;
|
|
240
383
|
/**
|
|
@@ -251,6 +394,14 @@ declare function attachPageAgent(): () => void;
|
|
|
251
394
|
* Returns whether anything scrolled.
|
|
252
395
|
*/
|
|
253
396
|
declare function scrollIntoContainers(el: Element): boolean;
|
|
397
|
+
/**
|
|
398
|
+
* After {@link scrollIntoContainers}: when a sticky header or footer still
|
|
399
|
+
* covers part of the target (`visibility.bands`), scroll its nearest
|
|
400
|
+
* vertical scroll container (else the window) so the target sits in the
|
|
401
|
+
* middle of the uncovered area (`visibility.bounds`), or starts at its top
|
|
402
|
+
* when it is taller. Returns whether anything scrolled.
|
|
403
|
+
*/
|
|
404
|
+
declare function nudgeIntoBounds(el: Element, visibility: TargetVisibility): boolean;
|
|
254
405
|
|
|
255
406
|
/**
|
|
256
407
|
* A same-origin app path, normalised, or null. Accepts only paths starting
|
|
@@ -301,6 +452,9 @@ interface ResolveOptions {
|
|
|
301
452
|
/**
|
|
302
453
|
* Only `role="tab"` elements count (for `select_tab`): a text match is
|
|
303
454
|
* promoted to its enclosing tab, and matches outside any tab are dropped.
|
|
455
|
+
* A tab in a collapsed row (see `tabs.ts`) counts as visible while its
|
|
456
|
+
* stand-in menu is, and resolves to the tab itself (the thing to click).
|
|
457
|
+
* Without this flag such a tab resolves to the visible menu instead.
|
|
304
458
|
*/
|
|
305
459
|
tabsOnly?: boolean;
|
|
306
460
|
}
|
|
@@ -328,7 +482,11 @@ declare function listTargets(options?: {
|
|
|
328
482
|
kind?: AgentTargetKind;
|
|
329
483
|
limit?: number;
|
|
330
484
|
}): TargetSummary[];
|
|
331
|
-
/**
|
|
485
|
+
/**
|
|
486
|
+
* Every `[role="tab"]` outside ignored regions, with label and state. A tab
|
|
487
|
+
* in a collapsed row (see `tabs.ts`) is `visible` while its stand-in menu
|
|
488
|
+
* is, and carries `shownAs: 'menu'`.
|
|
489
|
+
*/
|
|
332
490
|
declare function listTabs(options?: {
|
|
333
491
|
limit?: number;
|
|
334
492
|
}): TabSummary[];
|
|
@@ -402,55 +560,40 @@ declare function viewportSize(): ViewportSize;
|
|
|
402
560
|
declare function prefersReducedMotion(): boolean;
|
|
403
561
|
|
|
404
562
|
/**
|
|
405
|
-
*
|
|
406
|
-
*
|
|
563
|
+
* Boxes of the ancestors that clip `el` (any `overflow` other than
|
|
564
|
+
* `visible`), innermost first, up to the first `position: fixed` one (its
|
|
565
|
+
* ancestors do not clip it). Absolutely positioned elements that escape a
|
|
566
|
+
* non-positioned clipper are over-clipped; menus and popovers are usually
|
|
567
|
+
* portalled, so this is rare.
|
|
407
568
|
*/
|
|
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
|
-
}
|
|
569
|
+
declare function clippingBoxes(el: Element): Box[];
|
|
436
570
|
/**
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
571
|
+
* Inside an `inert` subtree, or not inside the topmost open modal dialog
|
|
572
|
+
* (`aria-modal="true"` or a `<dialog>` opened with `showModal()`). Modals
|
|
573
|
+
* that are not rendered (closed, `display: none`) and the layer's own UI do
|
|
574
|
+
* not count. Topmost = last in document order, which is where portalled
|
|
575
|
+
* dialogs land.
|
|
441
576
|
*/
|
|
442
|
-
declare function
|
|
443
|
-
gap?: number;
|
|
444
|
-
preferBelow?: boolean;
|
|
445
|
-
}): LabelPlacement;
|
|
577
|
+
declare function isBehindModal(el: Element): boolean;
|
|
446
578
|
/**
|
|
447
|
-
* Where the
|
|
448
|
-
*
|
|
449
|
-
*
|
|
579
|
+
* Where (and whether) the user can see `el` right now: clipped to the
|
|
580
|
+
* viewport and its scroll boxes, with covers (sticky headers, dialogs,
|
|
581
|
+
* docks) hit-tested. See {@link computeVisibility} for the rules. Style
|
|
582
|
+
* checks (`hidden`, `display: none`, …) are not repeated here; callers
|
|
583
|
+
* check {@link isRendered} first. Cheap enough per animation frame for a
|
|
584
|
+
* handful of effects (9 hit tests each).
|
|
450
585
|
*/
|
|
451
|
-
declare function
|
|
586
|
+
declare function measureVisibility(el: Element): TargetVisibility;
|
|
587
|
+
|
|
588
|
+
declare const TABLIST_MENU_ATTR = "data-tablist-menu";
|
|
589
|
+
/**
|
|
590
|
+
* For a tab in a collapsed tab row: the visible stand-in control, or null
|
|
591
|
+
* when the row is collapsed but its stand-in is missing, ignored or not
|
|
592
|
+
* rendered. `undefined` when the tab is not in a collapsed row.
|
|
593
|
+
*/
|
|
594
|
+
declare function collapsedTabMenu(tab: Element): Element | null | undefined;
|
|
452
595
|
|
|
453
596
|
/** Back to a clean slate: default config, no effects. Registrations are kept (their owners destroy them). */
|
|
454
597
|
declare function resetPageAgent(): void;
|
|
455
598
|
|
|
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 };
|
|
599
|
+
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,141 @@ 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 viewportBox = (viewport) => ({
|
|
215
|
+
top: 0,
|
|
216
|
+
left: 0,
|
|
217
|
+
width: viewport.width,
|
|
218
|
+
height: viewport.height
|
|
219
|
+
});
|
|
220
|
+
function intersectBoxes(a, b) {
|
|
221
|
+
const top = Math.max(a.top, b.top);
|
|
222
|
+
const left = Math.max(a.left, b.left);
|
|
223
|
+
const right = Math.min(a.left + a.width, b.left + b.width);
|
|
224
|
+
const bottom = Math.min(a.top + a.height, b.top + b.height);
|
|
225
|
+
if (right <= left || bottom <= top) return null;
|
|
226
|
+
return { top, left, width: right - left, height: bottom - top };
|
|
227
|
+
}
|
|
228
|
+
function containsBox(outer, inner) {
|
|
229
|
+
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;
|
|
230
|
+
}
|
|
231
|
+
var containsPoint = (box, x, y) => x >= box.left && x <= box.left + box.width && y >= box.top && y <= box.top + box.height;
|
|
232
|
+
var NO_CUT = { top: false, right: false, bottom: false, left: false };
|
|
233
|
+
function computeVisibility(input) {
|
|
234
|
+
if (!input.rect) {
|
|
235
|
+
return { state: "unrendered", rect: null, visible: null, bounds: null, cut: NO_CUT, bands: [] };
|
|
236
|
+
}
|
|
237
|
+
const rect = toBox(input.rect);
|
|
238
|
+
const vp = viewportBox(input.viewport);
|
|
239
|
+
let bounds = vp;
|
|
240
|
+
for (const clip of input.clips ?? []) {
|
|
241
|
+
bounds = intersectBoxes(bounds, clip);
|
|
242
|
+
if (!bounds) break;
|
|
243
|
+
}
|
|
244
|
+
const result = (state, visible2, extra = {}) => ({
|
|
245
|
+
state,
|
|
246
|
+
rect,
|
|
247
|
+
visible: visible2,
|
|
248
|
+
bounds,
|
|
249
|
+
cut: NO_CUT,
|
|
250
|
+
bands: [],
|
|
251
|
+
...extra
|
|
252
|
+
});
|
|
253
|
+
if (!intersectBoxes(rect, vp)) return result("offscreen", null);
|
|
254
|
+
if (input.behindModal) return result("covered", null, { coveredBy: "modal" });
|
|
255
|
+
let visible = bounds ? intersectBoxes(rect, bounds) : null;
|
|
256
|
+
if (!visible || visible.width < MIN_VISIBLE || visible.height < MIN_VISIBLE) {
|
|
257
|
+
return result("clipped", null);
|
|
258
|
+
}
|
|
259
|
+
const bands = [];
|
|
260
|
+
if (input.coverAt) {
|
|
261
|
+
const n = Math.min(Math.max(Math.round(input.grid ?? 3), 1), 5);
|
|
262
|
+
const samples = [];
|
|
263
|
+
for (let row = 0; row < n; row++) {
|
|
264
|
+
for (let col = 0; col < n; col++) {
|
|
265
|
+
const x = visible.left + visible.width * (col + 0.5) / n;
|
|
266
|
+
const y = visible.top + visible.height * (row + 0.5) / n;
|
|
267
|
+
const hit = input.coverAt(x, y);
|
|
268
|
+
const cover = hit ? toBox(hit) : null;
|
|
269
|
+
samples.push({ x, y, cover: cover && containsPoint(cover, x, y) ? cover : null });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const covers = [];
|
|
273
|
+
for (const s of samples) {
|
|
274
|
+
if (s.cover && !covers.some((c) => sameBox(c, s.cover))) covers.push(s.cover);
|
|
275
|
+
}
|
|
276
|
+
let trimmed = true;
|
|
277
|
+
while (trimmed && visible) {
|
|
278
|
+
trimmed = false;
|
|
279
|
+
for (const cover of covers) {
|
|
280
|
+
if (bands.includes(cover) || !visible || !intersectBoxes(cover, visible)) continue;
|
|
281
|
+
if (containsBox(cover, visible)) {
|
|
282
|
+
const edge = bounds && !containsBox(cover, bounds) ? trimBand(bounds, cover) : null;
|
|
283
|
+
if (edge?.visible) {
|
|
284
|
+
bounds = edge.visible;
|
|
285
|
+
bands.push(cover);
|
|
286
|
+
}
|
|
287
|
+
return result("covered", null, { coveredBy: "element", bands });
|
|
288
|
+
}
|
|
289
|
+
const band = trimBand(visible, cover);
|
|
290
|
+
if (!band) continue;
|
|
291
|
+
visible = band.visible;
|
|
292
|
+
bounds = bounds ? cutBox(bounds, band.side, band.line) : null;
|
|
293
|
+
bands.push(cover);
|
|
294
|
+
trimmed = true;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (!visible || visible.width < MIN_VISIBLE || visible.height < MIN_VISIBLE) {
|
|
298
|
+
return result("covered", null, { coveredBy: "element", bands });
|
|
299
|
+
}
|
|
300
|
+
const inside = samples.filter((s) => containsPoint(visible, s.x, s.y));
|
|
301
|
+
const spotted = inside.filter((s) => s.cover && !bands.includes(s.cover)).length;
|
|
302
|
+
if (inside.length > 0 && spotted * 2 > inside.length) {
|
|
303
|
+
return result("covered", null, { coveredBy: "element", bands });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const cut = cutSides(rect, visible);
|
|
307
|
+
const whole = !cut.top && !cut.right && !cut.bottom && !cut.left;
|
|
308
|
+
return { state: whole ? "visible" : "partial", rect, visible, bounds, cut, bands };
|
|
309
|
+
}
|
|
310
|
+
function sameBox(a, b) {
|
|
311
|
+
return a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height;
|
|
312
|
+
}
|
|
313
|
+
function cutSides(rect, visible) {
|
|
314
|
+
const half = 0.5;
|
|
315
|
+
return {
|
|
316
|
+
top: visible.top > rect.top + half,
|
|
317
|
+
left: visible.left > rect.left + half,
|
|
318
|
+
bottom: visible.top + visible.height < rect.top + rect.height - half,
|
|
319
|
+
right: visible.left + visible.width < rect.left + rect.width - half
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function cutBox(box, side, line) {
|
|
323
|
+
let { top, left } = box;
|
|
324
|
+
let bottom = box.top + box.height;
|
|
325
|
+
let right = box.left + box.width;
|
|
326
|
+
if (side === "top") top = Math.max(top, line);
|
|
327
|
+
if (side === "bottom") bottom = Math.min(bottom, line);
|
|
328
|
+
if (side === "left") left = Math.max(left, line);
|
|
329
|
+
if (side === "right") right = Math.min(right, line);
|
|
330
|
+
if (right <= left || bottom <= top) return null;
|
|
331
|
+
return { top, left, width: right - left, height: bottom - top };
|
|
332
|
+
}
|
|
333
|
+
function trimBand(visible, cover) {
|
|
334
|
+
const vRight = visible.left + visible.width;
|
|
335
|
+
const vBottom = visible.top + visible.height;
|
|
336
|
+
const cRight = cover.left + cover.width;
|
|
337
|
+
const cBottom = cover.top + cover.height;
|
|
338
|
+
const spansX = cover.left <= visible.left + EDGE && cRight >= vRight - EDGE;
|
|
339
|
+
const spansY = cover.top <= visible.top + EDGE && cBottom >= vBottom - EDGE;
|
|
340
|
+
const trim = (side, line) => ({ visible: cutBox(visible, side, line), side, line });
|
|
341
|
+
if (spansX && cover.top <= visible.top + EDGE) return trim("top", cBottom);
|
|
342
|
+
if (spansX && cBottom >= vBottom - EDGE) return trim("bottom", cover.top);
|
|
343
|
+
if (spansY && cover.left <= visible.left + EDGE) return trim("left", cRight);
|
|
344
|
+
if (spansY && cRight >= vRight - EDGE) return trim("right", cover.left);
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
201
347
|
|
|
202
348
|
// src/registry.ts
|
|
203
349
|
var TARGET_LABEL_MAX = 80;
|
|
@@ -302,6 +448,30 @@ function allTargets(root) {
|
|
|
302
448
|
return out;
|
|
303
449
|
}
|
|
304
450
|
|
|
451
|
+
// src/tabs.ts
|
|
452
|
+
var TABLIST_MENU_ATTR = "data-tablist-menu";
|
|
453
|
+
function collapsedTabMenu(tab) {
|
|
454
|
+
const list = tab.closest('[role="tablist"]');
|
|
455
|
+
if (!list || !list.hasAttribute(TABLIST_MENU_ATTR)) return void 0;
|
|
456
|
+
const id2 = (list.getAttribute(TABLIST_MENU_ATTR) ?? "").trim();
|
|
457
|
+
const menu = id2 ? tab.ownerDocument.getElementById(id2) : null;
|
|
458
|
+
if (!menu || menu === list || list.contains(menu) || menu.contains(list)) return null;
|
|
459
|
+
if (isIgnored(menu) || !isRendered(menu)) return null;
|
|
460
|
+
return menu;
|
|
461
|
+
}
|
|
462
|
+
function collapsedStandIn(el) {
|
|
463
|
+
const tab = el.closest('[role="tab"]');
|
|
464
|
+
return tab ? collapsedTabMenu(tab) ?? null : null;
|
|
465
|
+
}
|
|
466
|
+
function isTabDisabled(el) {
|
|
467
|
+
if (el.getAttribute("aria-disabled") === "true" || el.hasAttribute("disabled") || el.hasAttribute("data-disabled")) {
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
const menu = collapsedTabMenu(el);
|
|
471
|
+
if (menu !== void 0) return menu !== null && menu.closest("[inert]") !== null;
|
|
472
|
+
return el.closest("[inert]") !== null;
|
|
473
|
+
}
|
|
474
|
+
|
|
305
475
|
// src/resolve.ts
|
|
306
476
|
var TARGET_ID_MAX = 128;
|
|
307
477
|
var TARGET_TEXT_MAX = 200;
|
|
@@ -311,7 +481,7 @@ var TEXT_LENGTH_MAX = 2e3;
|
|
|
311
481
|
var SKIP_TAGS = /* @__PURE__ */ new Set(["script", "style", "template", "noscript", "head", "title", "meta"]);
|
|
312
482
|
function resolveTarget(ref, options = {}) {
|
|
313
483
|
if (!hasDom()) return { status: "not_found", message: "No page is available." };
|
|
314
|
-
if ("id" in ref) return resolveById(ref.id);
|
|
484
|
+
if ("id" in ref) return resolveById(ref.id, options);
|
|
315
485
|
let scope = document.body ?? document.documentElement;
|
|
316
486
|
if (ref.within !== void 0) {
|
|
317
487
|
const parent = resolveById(ref.within);
|
|
@@ -325,16 +495,24 @@ function resolveTarget(ref, options = {}) {
|
|
|
325
495
|
function targetIdOf(el) {
|
|
326
496
|
return getAgentTargets().find((t) => t.el === el)?.id ?? markupId(el);
|
|
327
497
|
}
|
|
328
|
-
function resolveById(id2) {
|
|
498
|
+
function resolveById(id2, options = {}) {
|
|
329
499
|
const matches = allTargets().filter((t) => t.id === id2 && !isIgnored(t.el));
|
|
330
500
|
if (matches.length === 0) {
|
|
331
501
|
return { status: "not_found", message: `No target with id "${quote(id2)}".` };
|
|
332
502
|
}
|
|
333
503
|
const visible = matches.find((t) => isRendered(t.el));
|
|
334
|
-
if (
|
|
335
|
-
|
|
504
|
+
if (visible) return { status: "ok", el: visible.el, id: visible.id, label: visible.label };
|
|
505
|
+
for (const t of matches) {
|
|
506
|
+
const shown = shownElement(t.el, options);
|
|
507
|
+
if (shown) return { status: "ok", el: shown, id: t.id, label: t.label, shownAs: "menu" };
|
|
508
|
+
}
|
|
509
|
+
return { status: "hidden", message: `Target "${quote(id2)}" is not visible right now.` };
|
|
510
|
+
}
|
|
511
|
+
function shownElement(el, options) {
|
|
512
|
+
if (options.tabsOnly) {
|
|
513
|
+
return el.getAttribute("role") === "tab" && collapsedTabMenu(el) ? el : null;
|
|
336
514
|
}
|
|
337
|
-
return
|
|
515
|
+
return collapsedStandIn(el);
|
|
338
516
|
}
|
|
339
517
|
function resolveByText(text, scope, options) {
|
|
340
518
|
const query = normalizeText(text);
|
|
@@ -364,7 +542,18 @@ function resolveByText(text, scope, options) {
|
|
|
364
542
|
};
|
|
365
543
|
}
|
|
366
544
|
const rendered = /* @__PURE__ */ new Map();
|
|
367
|
-
const
|
|
545
|
+
const shown = /* @__PURE__ */ new Map();
|
|
546
|
+
for (const el of matches) {
|
|
547
|
+
if (isRendered(el, rendered)) {
|
|
548
|
+
if (!shown.has(el)) shown.set(el, null);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const standIn = shownElement(el, options);
|
|
552
|
+
if (standIn && !shown.get(standIn)) {
|
|
553
|
+
shown.set(standIn, { tab: el.closest('[role="tab"]') ?? el });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const visible = Array.from(shown.keys());
|
|
368
557
|
if (visible.length === 0) {
|
|
369
558
|
return {
|
|
370
559
|
status: "hidden",
|
|
@@ -373,8 +562,16 @@ function resolveByText(text, scope, options) {
|
|
|
373
562
|
}
|
|
374
563
|
if (visible.length === 1) {
|
|
375
564
|
const el = visible[0];
|
|
376
|
-
const
|
|
377
|
-
|
|
565
|
+
const collapsed = shown.get(el);
|
|
566
|
+
const named = collapsed ? collapsed.tab : el;
|
|
567
|
+
const id2 = targetIdOf(named);
|
|
568
|
+
return {
|
|
569
|
+
status: "ok",
|
|
570
|
+
el,
|
|
571
|
+
...id2 ? { id: id2 } : {},
|
|
572
|
+
label: labelFor(named, id2 ?? query) || query,
|
|
573
|
+
...collapsed ? { shownAs: "menu" } : {}
|
|
574
|
+
};
|
|
378
575
|
}
|
|
379
576
|
return {
|
|
380
577
|
status: "ambiguous",
|
|
@@ -417,6 +614,103 @@ function quote(value) {
|
|
|
417
614
|
return cleanText(value, 60).replace(/"/g, "'");
|
|
418
615
|
}
|
|
419
616
|
|
|
617
|
+
// src/visibility.ts
|
|
618
|
+
var CLIPS = /^(hidden|clip|auto|scroll|overlay)$/;
|
|
619
|
+
var COVER_WALK_MAX = 16;
|
|
620
|
+
function defaultHitTest(x, y) {
|
|
621
|
+
if (typeof document.elementsFromPoint !== "function") return null;
|
|
622
|
+
for (const el of document.elementsFromPoint(x, y)) {
|
|
623
|
+
if (!el.closest(`.${LAYER_CLASS}`)) return el;
|
|
624
|
+
}
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
function hitTest(x, y) {
|
|
628
|
+
const custom = getPageAgentConfig().hitTest;
|
|
629
|
+
try {
|
|
630
|
+
return custom ? custom(x, y) : defaultHitTest(x, y);
|
|
631
|
+
} catch {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
var toBox2 = (r) => ({ top: r.top, left: r.left, width: r.width, height: r.height });
|
|
636
|
+
function clippingBoxes(el) {
|
|
637
|
+
const view = el.ownerDocument.defaultView;
|
|
638
|
+
if (!view) return [];
|
|
639
|
+
const out = [];
|
|
640
|
+
if (view.getComputedStyle(el).position === "fixed") return out;
|
|
641
|
+
const doc = el.ownerDocument;
|
|
642
|
+
for (let node = el.parentElement; node && node !== doc.body && node !== doc.documentElement; node = node.parentElement) {
|
|
643
|
+
const style = view.getComputedStyle(node);
|
|
644
|
+
if (CLIPS.test(style.overflowX) || CLIPS.test(style.overflowY) || CLIPS.test(style.overflow)) {
|
|
645
|
+
const rect = layoutRect(node);
|
|
646
|
+
if (rect) out.push(paddingBox(node, rect));
|
|
647
|
+
}
|
|
648
|
+
if (style.position === "fixed") break;
|
|
649
|
+
}
|
|
650
|
+
return out;
|
|
651
|
+
}
|
|
652
|
+
function paddingBox(node, rect) {
|
|
653
|
+
if (node.clientWidth > 0 && node.clientHeight > 0) {
|
|
654
|
+
return {
|
|
655
|
+
top: rect.top + node.clientTop,
|
|
656
|
+
left: rect.left + node.clientLeft,
|
|
657
|
+
width: Math.min(node.clientWidth, rect.width),
|
|
658
|
+
height: Math.min(node.clientHeight, rect.height)
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
return toBox2(rect);
|
|
662
|
+
}
|
|
663
|
+
function isModalDialog(el) {
|
|
664
|
+
if (el.getAttribute("aria-modal") === "true") return true;
|
|
665
|
+
if (el.tagName === "DIALOG") {
|
|
666
|
+
try {
|
|
667
|
+
return el.matches(":modal");
|
|
668
|
+
} catch {
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
function isBehindModal(el) {
|
|
675
|
+
if (el.closest("[inert]")) return true;
|
|
676
|
+
const doc = el.ownerDocument;
|
|
677
|
+
const modals = Array.from(doc.querySelectorAll('[aria-modal="true"], dialog')).filter(
|
|
678
|
+
(m) => isModalDialog(m) && !m.closest(`.${LAYER_CLASS}`) && isRendered(m)
|
|
679
|
+
);
|
|
680
|
+
const top = modals[modals.length - 1];
|
|
681
|
+
return !!top && !top.contains(el);
|
|
682
|
+
}
|
|
683
|
+
function coverBox(el, x, y) {
|
|
684
|
+
const hit = hitTest(x, y);
|
|
685
|
+
if (!hit || hit === el || el.contains(hit) || hit.contains(el)) return null;
|
|
686
|
+
let box = layoutRect(hit);
|
|
687
|
+
if (!box) return null;
|
|
688
|
+
let node = hit.parentElement;
|
|
689
|
+
for (let i = 0; node && i < COVER_WALK_MAX && !node.contains(el); i++) {
|
|
690
|
+
const outer = layoutRect(node);
|
|
691
|
+
if (!outer || !encloses(outer, box)) break;
|
|
692
|
+
box = outer;
|
|
693
|
+
node = node.parentElement;
|
|
694
|
+
}
|
|
695
|
+
return box;
|
|
696
|
+
}
|
|
697
|
+
function encloses(outer, inner) {
|
|
698
|
+
return outer.top <= inner.top + 1 && outer.left <= inner.left + 1 && outer.bottom >= inner.bottom - 1 && outer.right >= inner.right - 1;
|
|
699
|
+
}
|
|
700
|
+
function measureVisibility(el) {
|
|
701
|
+
const rect = hasDom() && el.isConnected ? layoutRect(el) : null;
|
|
702
|
+
if (!rect) {
|
|
703
|
+
return computeVisibility({ rect: null, viewport: viewportSize() });
|
|
704
|
+
}
|
|
705
|
+
return computeVisibility({
|
|
706
|
+
rect,
|
|
707
|
+
viewport: viewportSize(),
|
|
708
|
+
clips: clippingBoxes(el),
|
|
709
|
+
behindModal: isBehindModal(el),
|
|
710
|
+
coverAt: (x, y) => coverBox(el, x, y)
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
420
714
|
// src/controller.ts
|
|
421
715
|
var EFFECT_DEFAULT_MS = 8e3;
|
|
422
716
|
var EFFECT_MIN_MS = 1e3;
|
|
@@ -496,12 +790,22 @@ function add(kind, resolved, label2, durationMs2) {
|
|
|
496
790
|
function highlight(ref, options = {}) {
|
|
497
791
|
const resolved = target(ref);
|
|
498
792
|
if (resolved.status !== "ok") return resolved;
|
|
499
|
-
|
|
500
|
-
return { status: "ok", effect: add(kind, resolved, options.label, options.durationMs) };
|
|
793
|
+
return highlightResolved(resolved, options);
|
|
501
794
|
}
|
|
502
795
|
function point(ref, options = {}) {
|
|
503
796
|
const resolved = target(ref);
|
|
504
797
|
if (resolved.status !== "ok") return resolved;
|
|
798
|
+
return pointResolved(resolved, options);
|
|
799
|
+
}
|
|
800
|
+
function highlightResolved(resolved, options = {}) {
|
|
801
|
+
if (!resolved.el.isConnected)
|
|
802
|
+
return { status: "not_found", message: "Element is not available." };
|
|
803
|
+
const kind = options.style === "spotlight" ? "spotlight" : "outline";
|
|
804
|
+
return { status: "ok", effect: add(kind, resolved, options.label, options.durationMs) };
|
|
805
|
+
}
|
|
806
|
+
function pointResolved(resolved, options = {}) {
|
|
807
|
+
if (!resolved.el.isConnected)
|
|
808
|
+
return { status: "not_found", message: "Element is not available." };
|
|
505
809
|
return { status: "ok", effect: add("pointer", resolved, options.label, options.durationMs) };
|
|
506
810
|
}
|
|
507
811
|
function scrollToTarget(ref) {
|
|
@@ -531,6 +835,9 @@ function onEffectsChange(listener) {
|
|
|
531
835
|
function rectOf(effect) {
|
|
532
836
|
return layoutRect(effect.el);
|
|
533
837
|
}
|
|
838
|
+
function visibilityOf(effect) {
|
|
839
|
+
return measureVisibility(effect.el);
|
|
840
|
+
}
|
|
534
841
|
function notifyNavigated() {
|
|
535
842
|
clearEffects();
|
|
536
843
|
}
|
|
@@ -606,6 +913,34 @@ function scrollIntoContainers(el) {
|
|
|
606
913
|
}
|
|
607
914
|
return scrolled;
|
|
608
915
|
}
|
|
916
|
+
function nudgeIntoBounds(el, visibility) {
|
|
917
|
+
if (typeof window === "undefined") return false;
|
|
918
|
+
const { rect, bounds } = visibility;
|
|
919
|
+
if (!rect || !bounds) return false;
|
|
920
|
+
const margin = 8;
|
|
921
|
+
const top = bounds.top + margin;
|
|
922
|
+
const room = bounds.height - margin * 2;
|
|
923
|
+
if (room <= 0) return false;
|
|
924
|
+
const bottom = rect.top + rect.height;
|
|
925
|
+
if (rect.top >= bounds.top && bottom <= bounds.top + bounds.height) return false;
|
|
926
|
+
const want = rect.height <= room ? rect.top - (top + (room - rect.height) / 2) : rect.top - top;
|
|
927
|
+
const view = el.ownerDocument.defaultView ?? window;
|
|
928
|
+
if (view.getComputedStyle(el).position === "fixed") return false;
|
|
929
|
+
const behavior = prefersReducedMotion() ? "auto" : "smooth";
|
|
930
|
+
for (let node = el.parentElement; node && node !== document.body && node !== document.documentElement; node = node.parentElement) {
|
|
931
|
+
const style = view.getComputedStyle(node);
|
|
932
|
+
if (style.position === "fixed") return false;
|
|
933
|
+
if (!isScrollable(node, style).y) continue;
|
|
934
|
+
const dy2 = clampScroll(node.scrollTop, want, node.scrollHeight - node.clientHeight);
|
|
935
|
+
if (!dy2) return false;
|
|
936
|
+
scrollBy(node, 0, dy2, behavior);
|
|
937
|
+
return true;
|
|
938
|
+
}
|
|
939
|
+
const dy = Math.round(Math.max(want, -(view.scrollY || 0)));
|
|
940
|
+
if (!dy) return false;
|
|
941
|
+
scrollBy(view, 0, dy, behavior);
|
|
942
|
+
return true;
|
|
943
|
+
}
|
|
609
944
|
function clampScroll(current, want, max) {
|
|
610
945
|
const next = Math.min(Math.max(current + want, 0), Math.max(max, 0));
|
|
611
946
|
return Math.round(next - current);
|
|
@@ -650,9 +985,6 @@ function collectTargets(options = {}) {
|
|
|
650
985
|
out.sort((a, b) => Number(b.visible) - Number(a.visible));
|
|
651
986
|
return { targets: out.slice(0, limit), total: out.length };
|
|
652
987
|
}
|
|
653
|
-
function isTabDisabled(el) {
|
|
654
|
-
return el.getAttribute("aria-disabled") === "true" || el.hasAttribute("disabled") || el.hasAttribute("data-disabled") || el.closest("[inert]") !== null;
|
|
655
|
-
}
|
|
656
988
|
function listTabs(options = {}) {
|
|
657
989
|
if (!hasDom()) return [];
|
|
658
990
|
const limit = clampLimit(options.limit, LIST_MAX);
|
|
@@ -662,12 +994,14 @@ function listTabs(options = {}) {
|
|
|
662
994
|
if (out.length >= limit) break;
|
|
663
995
|
if (isIgnored(el)) continue;
|
|
664
996
|
const id2 = targetIdOf(el);
|
|
997
|
+
const menu = collapsedTabMenu(el);
|
|
665
998
|
out.push({
|
|
666
999
|
...id2 ? { id: id2 } : {},
|
|
667
1000
|
label: labelFor(el, id2 ?? "") || "Tab",
|
|
668
1001
|
selected: el.getAttribute("aria-selected") === "true",
|
|
669
1002
|
disabled: isTabDisabled(el),
|
|
670
|
-
visible: isRendered(el, rendered)
|
|
1003
|
+
visible: menu === void 0 ? isRendered(el, rendered) : menu !== null,
|
|
1004
|
+
...menu !== void 0 ? { shownAs: "menu" } : {}
|
|
671
1005
|
});
|
|
672
1006
|
}
|
|
673
1007
|
return out;
|
|
@@ -966,12 +1300,50 @@ function failure(result) {
|
|
|
966
1300
|
function summary(resolved) {
|
|
967
1301
|
return { ...resolved.id ? { id: resolved.id } : {}, label: resolved.label };
|
|
968
1302
|
}
|
|
969
|
-
|
|
970
|
-
if (scrolled) return true;
|
|
971
|
-
const rect = layoutRect(el);
|
|
972
|
-
return !!rect && intersectsViewport(rect, viewportSize());
|
|
973
|
-
}
|
|
1303
|
+
var MENU_NOTE = "The tabs are shown as a drop-down menu here (the page is narrow), not as a row of tabs.";
|
|
974
1304
|
var wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1305
|
+
var SCROLL_SETTLE_MAX_MS = 1e3;
|
|
1306
|
+
var SCROLL_SETTLE_STEP_MS = 50;
|
|
1307
|
+
async function settleLayout(el) {
|
|
1308
|
+
const key = () => {
|
|
1309
|
+
const r = layoutRect(el);
|
|
1310
|
+
return r ? `${Math.round(r.top)},${Math.round(r.left)}` : "";
|
|
1311
|
+
};
|
|
1312
|
+
let last = key();
|
|
1313
|
+
let stable = 0;
|
|
1314
|
+
for (let waited = 0; waited < SCROLL_SETTLE_MAX_MS; waited += SCROLL_SETTLE_STEP_MS) {
|
|
1315
|
+
await wait(SCROLL_SETTLE_STEP_MS);
|
|
1316
|
+
const next = key();
|
|
1317
|
+
if (next === last) {
|
|
1318
|
+
if (++stable >= 2) return;
|
|
1319
|
+
} else {
|
|
1320
|
+
stable = 0;
|
|
1321
|
+
last = next;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
async function reveal(el, scrolledAlready) {
|
|
1326
|
+
let scrolled = scrolledAlready ?? scrollIntoContainers(el);
|
|
1327
|
+
if (scrolled) await settleLayout(el);
|
|
1328
|
+
let visibility = measureVisibility(el);
|
|
1329
|
+
if (visibility.bands.length > 0 && nudgeIntoBounds(el, visibility)) {
|
|
1330
|
+
scrolled = true;
|
|
1331
|
+
await settleLayout(el);
|
|
1332
|
+
visibility = measureVisibility(el);
|
|
1333
|
+
}
|
|
1334
|
+
return { scrolled, visibility };
|
|
1335
|
+
}
|
|
1336
|
+
var onScreen = (v) => v.state === "visible" || v.state === "partial";
|
|
1337
|
+
function coveredResult(visibility, label2) {
|
|
1338
|
+
if (visibility.state !== "covered") return null;
|
|
1339
|
+
const what = `"${label2.replace(/"/g, "'")}"`;
|
|
1340
|
+
const modal = visibility.coveredBy === "modal";
|
|
1341
|
+
return {
|
|
1342
|
+
status: "hidden",
|
|
1343
|
+
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.`,
|
|
1344
|
+
output: { reason: modal ? "behind_dialog" : "covered" }
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
975
1347
|
async function confirmSelected(el, settleMs) {
|
|
976
1348
|
const selected = () => el.isConnected && el.getAttribute("aria-selected") === "true";
|
|
977
1349
|
await Promise.resolve();
|
|
@@ -1005,7 +1377,7 @@ var handlers = {
|
|
|
1005
1377
|
};
|
|
1006
1378
|
return { status: "ok", output };
|
|
1007
1379
|
},
|
|
1008
|
-
highlight(raw) {
|
|
1380
|
+
async highlight(raw) {
|
|
1009
1381
|
const input = object(raw, "input", ["target", "style", "label", "durationMs"]);
|
|
1010
1382
|
const target2 = targetRef2(input.target);
|
|
1011
1383
|
let style = "outline";
|
|
@@ -1018,54 +1390,78 @@ var handlers = {
|
|
|
1018
1390
|
const durationMs2 = optionalDuration(input.durationMs);
|
|
1019
1391
|
const resolved = resolveTarget(target2);
|
|
1020
1392
|
if (resolved.status !== "ok") return failure(resolved);
|
|
1021
|
-
const scrolled =
|
|
1022
|
-
const
|
|
1393
|
+
const { scrolled, visibility } = await reveal(resolved.el);
|
|
1394
|
+
const covered = coveredResult(visibility, resolved.label);
|
|
1395
|
+
if (covered) return covered;
|
|
1396
|
+
if (!isRendered(resolved.el)) {
|
|
1397
|
+
return { status: "hidden", message: "That target is no longer visible." };
|
|
1398
|
+
}
|
|
1399
|
+
const outcome = highlightResolved(resolved, { style, label: label2, durationMs: durationMs2 });
|
|
1023
1400
|
if (outcome.status !== "ok") return failure(outcome);
|
|
1024
1401
|
if (!getEffects().some((e) => e.id === outcome.effect.id)) {
|
|
1025
1402
|
return { status: "error", message: "The highlight could not be shown." };
|
|
1026
1403
|
}
|
|
1027
1404
|
return {
|
|
1028
1405
|
status: "ok",
|
|
1406
|
+
...resolved.shownAs ? { message: `${MENU_NOTE} Highlighted that menu.` } : {},
|
|
1029
1407
|
output: {
|
|
1030
1408
|
effectId: outcome.effect.id,
|
|
1031
1409
|
target: summary(resolved),
|
|
1032
1410
|
style,
|
|
1033
1411
|
scrolled,
|
|
1034
|
-
inViewport:
|
|
1412
|
+
inViewport: onScreen(visibility),
|
|
1413
|
+
...resolved.shownAs ? { shownAs: resolved.shownAs } : {}
|
|
1035
1414
|
}
|
|
1036
1415
|
};
|
|
1037
1416
|
},
|
|
1038
|
-
point(raw) {
|
|
1417
|
+
async point(raw) {
|
|
1039
1418
|
const input = object(raw, "input", ["target", "label", "durationMs"]);
|
|
1040
1419
|
const target2 = targetRef2(input.target);
|
|
1041
1420
|
const label2 = optionalLabel(input.label);
|
|
1042
1421
|
const durationMs2 = optionalDuration(input.durationMs);
|
|
1043
1422
|
const resolved = resolveTarget(target2);
|
|
1044
1423
|
if (resolved.status !== "ok") return failure(resolved);
|
|
1045
|
-
const scrolled =
|
|
1046
|
-
const
|
|
1424
|
+
const { scrolled, visibility } = await reveal(resolved.el);
|
|
1425
|
+
const covered = coveredResult(visibility, resolved.label);
|
|
1426
|
+
if (covered) return covered;
|
|
1427
|
+
if (!isRendered(resolved.el)) {
|
|
1428
|
+
return { status: "hidden", message: "That target is no longer visible." };
|
|
1429
|
+
}
|
|
1430
|
+
const outcome = pointResolved(resolved, { label: label2, durationMs: durationMs2 });
|
|
1047
1431
|
if (outcome.status !== "ok") return failure(outcome);
|
|
1048
1432
|
if (!getEffects().some((e) => e.id === outcome.effect.id)) {
|
|
1049
1433
|
return { status: "error", message: "The pointer could not be shown." };
|
|
1050
1434
|
}
|
|
1051
1435
|
return {
|
|
1052
1436
|
status: "ok",
|
|
1437
|
+
...resolved.shownAs ? { message: `${MENU_NOTE} Pointed at that menu.` } : {},
|
|
1053
1438
|
output: {
|
|
1054
1439
|
effectId: outcome.effect.id,
|
|
1055
1440
|
target: summary(resolved),
|
|
1056
1441
|
scrolled,
|
|
1057
|
-
inViewport:
|
|
1442
|
+
inViewport: onScreen(visibility),
|
|
1443
|
+
...resolved.shownAs ? { shownAs: resolved.shownAs } : {}
|
|
1058
1444
|
}
|
|
1059
1445
|
};
|
|
1060
1446
|
},
|
|
1061
|
-
scroll_to(raw) {
|
|
1447
|
+
async scroll_to(raw) {
|
|
1062
1448
|
const input = object(raw, "input", ["target"]);
|
|
1063
1449
|
const target2 = targetRef2(input.target);
|
|
1064
1450
|
const resolved = resolveTarget(target2);
|
|
1065
1451
|
if (resolved.status !== "ok") return failure(resolved);
|
|
1066
1452
|
const outcome = scrollToTarget(resolved.el);
|
|
1067
1453
|
if (outcome.status !== "ok") return failure(outcome);
|
|
1068
|
-
|
|
1454
|
+
const { scrolled, visibility } = await reveal(resolved.el, outcome.scrolled);
|
|
1455
|
+
return {
|
|
1456
|
+
status: "ok",
|
|
1457
|
+
output: {
|
|
1458
|
+
target: summary(resolved),
|
|
1459
|
+
scrolled,
|
|
1460
|
+
inViewport: onScreen(visibility),
|
|
1461
|
+
...visibility.state === "covered" ? { covered: true } : {},
|
|
1462
|
+
...resolved.shownAs ? { shownAs: resolved.shownAs } : {}
|
|
1463
|
+
}
|
|
1464
|
+
};
|
|
1069
1465
|
},
|
|
1070
1466
|
clear_highlights(raw) {
|
|
1071
1467
|
optionalInput(raw, []);
|
|
@@ -1085,15 +1481,30 @@ var handlers = {
|
|
|
1085
1481
|
if (!(el instanceof HTMLElement))
|
|
1086
1482
|
return { status: "refused", message: "That tab cannot be activated." };
|
|
1087
1483
|
if (isTabDisabled(el)) return { status: "refused", message: "That tab is disabled." };
|
|
1484
|
+
const menu = collapsedTabMenu(el);
|
|
1485
|
+
const asMenu = menu !== void 0;
|
|
1486
|
+
const extra = asMenu ? { shownAs: "menu" } : {};
|
|
1088
1487
|
if (el.getAttribute("aria-selected") === "true") {
|
|
1089
|
-
return {
|
|
1488
|
+
return {
|
|
1489
|
+
status: "ok",
|
|
1490
|
+
...asMenu ? { message: MENU_NOTE } : {},
|
|
1491
|
+
output: { target: summary(resolved), alreadySelected: true, ...extra }
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
const shown = asMenu ? menu : isRendered(el) ? el : null;
|
|
1495
|
+
if (!shown) return { status: "hidden", message: "That tab is not visible right now." };
|
|
1496
|
+
if (isBehindModal(shown)) {
|
|
1497
|
+
return { status: "hidden", message: "That tab is behind a dialog right now." };
|
|
1090
1498
|
}
|
|
1091
|
-
if (!isRendered(el)) return { status: "hidden", message: "That tab is not visible right now." };
|
|
1092
1499
|
el.click();
|
|
1093
1500
|
if (!await confirmSelected(el, getPageAgentConfig().tabSettleMs)) {
|
|
1094
1501
|
return { status: "error", message: "The tab did not become selected." };
|
|
1095
1502
|
}
|
|
1096
|
-
return {
|
|
1503
|
+
return {
|
|
1504
|
+
status: "ok",
|
|
1505
|
+
...asMenu ? { message: `${MENU_NOTE} The menu now shows this tab.` } : {},
|
|
1506
|
+
output: { target: summary(resolved), alreadySelected: false, ...extra }
|
|
1507
|
+
};
|
|
1097
1508
|
},
|
|
1098
1509
|
async navigate(raw) {
|
|
1099
1510
|
const input = object(raw, "input", ["path"]);
|
|
@@ -1133,4 +1544,4 @@ function resetPageAgent() {
|
|
|
1133
1544
|
resetPageAgentConfig();
|
|
1134
1545
|
}
|
|
1135
1546
|
|
|
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 };
|
|
1547
|
+
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|