@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 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 `rectOf(effect)` for drawing. Effects expire
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
- - **Geometry**: `unionRects`, `inflate`, `clampToViewport`, `placeLabel`,
49
- `pointerTarget` for the UI layer.
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 to the target |
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 navigation resolved).
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). Dark mode follows `.dark` / `[data-theme='dark']`; reduced
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
- /** Every `[role="tab"]` outside ignored regions, with label and state. */
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
- * Pure rect maths for drawing effects. No DOM access: callers pass the
406
- * viewport size (see `viewportSize()` on the UI side).
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
- interface Box {
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
- * Where to put a caption of `size` next to a target rect: above its
438
- * top-left corner, or below when there is no room above (or `preferBelow`),
439
- * then clamped into the viewport. Same rules as feedback-core's selection
440
- * button, re-implemented to keep this package dependency-free.
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 placeLabel(rect: RectLike, size: Size, viewport: Size, opts?: {
443
- gap?: number;
444
- preferBelow?: boolean;
445
- }): LabelPlacement;
597
+ declare function isBehindModal(el: Element): boolean;
446
598
  /**
447
- * Where the ghost cursor's tip should rest: just inside the target's
448
- * bottom-right quarter, clamped into the viewport so it never points off
449
- * screen.
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 pointerTarget(rect: RectLike, viewport: Size, margin?: number): Point;
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 fitsAbove = box.top - size.height - gap >= 0;
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(Math.max(top, gap), Math.max(gap, viewport.height - size.height - gap));
189
- const left = Math.min(Math.max(box.left, gap), Math.max(gap, viewport.width - size.width - gap));
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 (!visible) {
335
- return { status: "hidden", message: `Target "${quote(id2)}" is not visible right now.` };
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 { status: "ok", el: visible.el, id: visible.id, label: visible.label };
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 visible = matches.filter((el) => isRendered(el, rendered));
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 id2 = targetIdOf(el);
377
- return { status: "ok", el, ...id2 ? { id: id2 } : {}, label: labelFor(el, id2 ?? query) };
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
- const kind = options.style === "spotlight" ? "spotlight" : "outline";
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
- function inViewport(el, scrolled = false) {
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 = scrollIntoContainers(resolved.el);
1022
- const outcome = highlight(resolved.el, { style, label: label2, durationMs: durationMs2 });
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: inViewport(resolved.el, scrolled)
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 = scrollIntoContainers(resolved.el);
1046
- const outcome = point(resolved.el, { label: label2, durationMs: durationMs2 });
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: inViewport(resolved.el, scrolled)
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
- return { status: "ok", output: { target: summary(resolved), scrolled: outcome.scrolled } };
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 { status: "ok", output: { target: summary(resolved), alreadySelected: true } };
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 { status: "ok", output: { target: summary(resolved), alreadySelected: false } };
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
- * <span class="chpa-tag" data-placement="above|below">caption</span>
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.0",
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",