@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 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,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
- - **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 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 to the target |
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 navigation resolved).
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). Dark mode follows `.dark` / `[data-theme='dark']`; reduced
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
- /** Every `[role="tab"]` outside ignored regions, with label and state. */
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
- * Pure rect maths for drawing effects. No DOM access: callers pass the
406
- * viewport size (see `viewportSize()` on the UI side).
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
- 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
- }
569
+ declare function clippingBoxes(el: Element): Box[];
436
570
  /**
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.
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 placeLabel(rect: RectLike, size: Size, viewport: Size, opts?: {
443
- gap?: number;
444
- preferBelow?: boolean;
445
- }): LabelPlacement;
577
+ declare function isBehindModal(el: Element): boolean;
446
578
  /**
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.
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 pointerTarget(rect: RectLike, viewport: Size, margin?: number): Point;
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 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,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 (!visible) {
335
- return { status: "hidden", message: `Target "${quote(id2)}" is not visible right now.` };
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 { status: "ok", el: visible.el, id: visible.id, label: visible.label };
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 visible = matches.filter((el) => isRendered(el, rendered));
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 id2 = targetIdOf(el);
377
- return { status: "ok", el, ...id2 ? { id: id2 } : {}, label: labelFor(el, id2 ?? query) };
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
- const kind = options.style === "spotlight" ? "spotlight" : "outline";
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
- function inViewport(el, scrolled = false) {
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 = scrollIntoContainers(resolved.el);
1022
- const outcome = highlight(resolved.el, { style, label: label2, durationMs: durationMs2 });
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: inViewport(resolved.el, scrolled)
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 = scrollIntoContainers(resolved.el);
1046
- const outcome = point(resolved.el, { label: label2, durationMs: durationMs2 });
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: inViewport(resolved.el, scrolled)
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
- return { status: "ok", output: { target: summary(resolved), scrolled: outcome.scrolled } };
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 { status: "ok", output: { target: summary(resolved), alreadySelected: true } };
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 { status: "ok", output: { target: summary(resolved), alreadySelected: false } };
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
- * <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.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",