@classic-homes/page-agent-core 0.0.0-stage → 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
@@ -1,3 +1,170 @@
1
- # Temporary Holding Version
1
+ # @classic-homes/page-agent-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Framework-agnostic page layer for an in-app AI agent: it lets the agent
4
+ **point at things on the host page** (outline, spotlight, a labelled ghost
5
+ cursor, scroll into view, switch a tab, go to another page) without ever
6
+ running model-generated selectors, HTML or code.
7
+
8
+ Most apps install the framework binding instead (it draws the effects):
9
+ `@classic-homes/page-agent-svelte`.
10
+
11
+ ```bash
12
+ npm install @classic-homes/page-agent-core
13
+ ```
14
+
15
+ ```css
16
+ @import '@classic-homes/page-agent-core/styles.css';
17
+ ```
18
+
19
+ No runtime dependencies. ESM only (the registry, config and effect store are
20
+ module singletons). Safe to import during SSR.
21
+
22
+ ## What is in here
23
+
24
+ - **Registry**: `registerAgentTarget({ el, id, label, kind })` →
25
+ `{ update, destroy }`, `getAgentTargets`, batched `onAgentTargetsChange`,
26
+ and `scanAgentAttributes` for markup targets (`data-agent-id`,
27
+ `data-agent-label`, `data-agent-kind`, `data-agent-description`, plus the
28
+ host's alias attributes such as `data-tour`).
29
+ - **Resolution**: `resolveTarget({ id } | { text, within? })`. By id
30
+ (registry, then markup), or by visible text (exact, then case-insensitive;
31
+ the smallest match, promoted to the button/tab/link carrying the same
32
+ text). Several matches return `ambiguous` with up to 8 candidates rather
33
+ than guessing.
34
+ - **Snapshot**: `listTargets()` (max 200, `{ id, label, kind, visible,
35
+ inViewport }`), `listTabs()` and `readPageText({ maxChars,
36
+ ignoreSelector })`.
37
+ - **Effects**: `highlight(ref, { style: 'outline' | 'spotlight', label,
38
+ durationMs })`, `point(ref, { label, durationMs })`, `scrollToTarget(ref)`,
39
+ `clearEffects()`, `getEffects()` / `onEffectsChange()` (safe for
40
+ `useSyncExternalStore`), and `visibilityOf(effect)` / `rectOf(effect)`
41
+ for drawing. Effects expire
42
+ (default 8 s, max 60 s); at most 8 at once, one pointer and one spotlight.
43
+ `attachPageAgent()` wires Escape and `popstate` to clear; call
44
+ `notifyNavigated()` after SPA navigation.
45
+ - **Executor**: `configurePageAgent({ navigate, aliasAttributes,
46
+ ignoreSelector })` and `executePageCall(call)`, the single entry point for
47
+ the model's tool calls. `PAGE_TOOL_NAMES` and `PAGE_TOOLS` (plain-JSON
48
+ input shapes) let the agent server mirror the contract exactly.
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.
60
+
61
+ ```ts
62
+ import {
63
+ attachPageAgent,
64
+ configurePageAgent,
65
+ executePageCall,
66
+ } from '@classic-homes/page-agent-core';
67
+ import { goto } from '$app/navigation';
68
+
69
+ configurePageAgent({ navigate: (path) => goto(path), aliasAttributes: ['data-tour'] });
70
+ const detach = attachPageAgent();
71
+
72
+ // From the chat's client-call hook:
73
+ const result = await executePageCall({
74
+ tool: 'highlight',
75
+ input: { target: { id: 'closing-date' } },
76
+ });
77
+ // → { status: 'ok', output: { effectId, target: { id, label }, style, scrolled, inViewport } }
78
+ ```
79
+
80
+ ```html
81
+ <section data-agent-id="closing-date" data-agent-label="Closing date">…</section>
82
+ <div data-agent-ignore><!-- never read, never pointed at --></div>
83
+ ```
84
+
85
+ ## Tools
86
+
87
+ | Tool | Input | Does |
88
+ | ------------------ | ----------------------------------------- | --------------------------------------------------------- |
89
+ | `list_targets` | `{ kind?, limit? }` | Named targets and tabs on the page |
90
+ | `highlight` | `{ target, style?, label?, durationMs? }` | Outline or spotlight, scrolling into view first if needed |
91
+ | `point` | `{ target, label?, durationMs? }` | Moves the ghost cursor, scrolling into view if needed |
92
+ | `scroll_to` | `{ target }` | Scrolls into view (no-op if already fully visible) |
93
+ | `clear_highlights` | `{}` | Removes every effect |
94
+ | `select_tab` | `{ target }` | Clicks a `role="tab"`, `ok` once `aria-selected="true"` |
95
+ | `navigate` | `{ path }` | Calls the host's `navigate` with a same-origin path |
96
+
97
+ `target` is `{ id }` or `{ text, within? }`. Results are `{ status, message?,
98
+ output? }` with `status` one of `ok`, `not_found`, `ambiguous`, `hidden`,
99
+ `refused`, `error`.
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
+
128
+ ## Security
129
+
130
+ `executePageCall` treats its input as untrusted:
131
+
132
+ - Every field is checked by hand (type, length, enum, range); unknown tools,
133
+ unknown keys and malformed input return `refused`. It never throws.
134
+ - Targets are ids (compared verbatim with attribute values) or visible
135
+ text. No CSS selectors, HTML or code are accepted.
136
+ - Nothing is clicked except a non-disabled `role="tab"` element (also in a
137
+ collapsed row; never its drop-down or any `<select>`).
138
+ - `navigate` accepts only paths starting with a single `/` (no `//`,
139
+ backslashes, schemes, whitespace or control characters, also after
140
+ decoding) and goes through the host's handler; without one it is refused.
141
+ - `ok` is reported only once the effect is in place (the effect is in the
142
+ store and its target is not covered, the tab is selected, the host's
143
+ navigation resolved).
144
+ - `[data-agent-ignore]`, the host `ignoreSelector` and the layer's own UI
145
+ (`.chpa`) are invisible to resolution, snapshots and `readPageText`, which
146
+ also never reads password, hidden, card or one-time-code field values.
147
+ Treat page text as untrusted data on the server.
148
+
149
+ ## Styles
150
+
151
+ `styles.css` holds the `.chpa-*` classes the bindings render: the
152
+ click-through layer (`.chpa-layer`), outlines (`.chpa-outline` +
153
+ `.chpa-tag`), the spotlight (`.chpa-spotlight`, dims with a spread shadow and
154
+ keeps the page clickable), the ghost cursor (`.chpa-pointer`,
155
+ `.chpa-pointer-icon`, `.chpa-pointer-label`), the "Agent is pointing ·
156
+ Dismiss" chip (`.chpa-chip`, `.chpa-chip-text`, `.chpa-chip-btn`, 44 px
157
+ target) and a live region (`.chpa-sr`). Override `--chpa-accent` (default
158
+ `#6d28d9`) and `--chpa-z` (default `2147482000`, below the Access
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
162
+ motion drops the glide and pulse.
163
+
164
+ ## Testing
165
+
166
+ jsdom has no layout, so pass `configurePageAgent({ measure })` to supply
167
+ element boxes. The style rules (`hidden`, `display: none`, `visibility`,
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 })`.