@zumer/snapdom-plugins 1.0.3 → 1.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 +41 -10
- package/package.json +1 -1
- package/prompt-export.js +345 -76
package/README.md
CHANGED
|
@@ -172,23 +172,33 @@ snapdom(el, { plugins: [htmlInCanvas()] });
|
|
|
172
172
|
|
|
173
173
|
### `prompt-export`
|
|
174
174
|
|
|
175
|
-
Adds a `toPrompt()` export method that returns an LLM-ready package:
|
|
175
|
+
Adds a `toPrompt()` export method that returns an LLM-ready package: a structured element map with bounding boxes, a pre-formatted prompt text, and (optionally) an annotated screenshot. Tuned for vision-language models, browser-agent pipelines, visual QA, and any workflow that pairs a capture with structured metadata.
|
|
176
176
|
|
|
177
177
|
```js
|
|
178
178
|
import { promptExport } from '@zumer/snapdom-plugins/prompt-export';
|
|
179
179
|
|
|
180
180
|
const result = await snapdom(el, { plugins: [promptExport()] });
|
|
181
|
-
|
|
181
|
+
// Default: no image, just the structured map + prompt text (cheapest)
|
|
182
|
+
const { elements, prompt, dimensions } = await result.toPrompt();
|
|
182
183
|
```
|
|
183
184
|
|
|
184
|
-
|
|
185
|
+
To also include the annotated image (for tasks that truly depend on vision):
|
|
186
|
+
|
|
187
|
+
```js
|
|
188
|
+
const result = await snapdom(el, {
|
|
189
|
+
plugins: [promptExport({ include: ['image', 'elements', 'prompt'] })]
|
|
190
|
+
});
|
|
191
|
+
const { image, elements, prompt, dimensions } = await result.toPrompt();
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The returned object (fields present only if requested via `include`):
|
|
185
195
|
|
|
186
196
|
| Field | Type | Description |
|
|
187
197
|
|-------|------|-------------|
|
|
188
|
-
| `
|
|
189
|
-
| `elements` | `Array` | One entry per detected element: `{ id, tag, type, text, bbox, attributes }` |
|
|
190
|
-
| `dimensions` | `{width, height}` | Image dimensions after `maxImageWidth` scaling |
|
|
198
|
+
| `elements` | `Array` | One entry per detected element: `{ id, tag, type, name, text, bbox, attributes, state?, styles?, covered? }` |
|
|
191
199
|
| `prompt` | `string` | Pre-formatted text describing interactive + semantic elements |
|
|
200
|
+
| `image` | `string` | Data URL of the (optionally annotated) screenshot — **only when `include` contains `'image'`** |
|
|
201
|
+
| `dimensions` | `{width, height}` | Scaled dimensions (always present) |
|
|
192
202
|
|
|
193
203
|
`elements` is split into two `type`s:
|
|
194
204
|
- `'interactive'` — buttons, links, inputs, `[role]`/`[tabindex]` targets. These get numbered badges overlaid on the screenshot when `annotate` is on.
|
|
@@ -196,9 +206,17 @@ The returned object:
|
|
|
196
206
|
|
|
197
207
|
Each `bbox` is in pixel coordinates of the returned image (scaled against `maxImageWidth`).
|
|
198
208
|
|
|
209
|
+
Each interactive entry also carries:
|
|
210
|
+
- `name` — the computed accessible name (aria-label → labelledby → alt → title → labels[0] → textContent)
|
|
211
|
+
- `state` — runtime state: `{ checked, disabled, focus, open, value, selectedText }` (only keys that apply)
|
|
212
|
+
- `styles` — visually-meaningful computed props filtered to drop defaults
|
|
213
|
+
- `covered: true` when another element is painted on top of the bbox center (an agent won't click through a modal)
|
|
214
|
+
|
|
199
215
|
```js
|
|
200
216
|
// Example — feed a vision-capable LLM
|
|
201
|
-
const { image, elements } = await result.toPrompt({
|
|
217
|
+
const { image, elements } = await result.toPrompt({
|
|
218
|
+
include: ['image', 'elements', 'prompt']
|
|
219
|
+
});
|
|
202
220
|
|
|
203
221
|
// image is a data URL → pass as image input
|
|
204
222
|
// elements is JSON → pass as structured context alongside the image
|
|
@@ -207,8 +225,11 @@ const { image, elements } = await result.toPrompt({ maxImageWidth: 1024 });
|
|
|
207
225
|
|
|
208
226
|
| Option | Type | Default | Description |
|
|
209
227
|
|--------|------|---------|-------------|
|
|
210
|
-
| `
|
|
211
|
-
| `
|
|
228
|
+
| `include` | `string[]` | `['elements', 'prompt']` | Fields to return. Add `'image'` for tasks that need vision (chart content, layout QA, canvas). Use `['prompt']` for the cheapest text-only mode. |
|
|
229
|
+
| `annotate` | `boolean` | `true` | Overlay numbered badges on interactive elements (only affects the image when included) |
|
|
230
|
+
| `promptMode` | `'compact' \| 'verbose'` | `'compact'` | Prompt text verbosity. Compact omits coords when badges are on the image. |
|
|
231
|
+
| `includeCoords` | `boolean` | `true` | Include bbox in the prompt text |
|
|
232
|
+
| `imageFormat` | `'png' \| 'jpg' \| 'webp'` | `'png'` | Output image format (only used when `image` is included) |
|
|
212
233
|
| `imageQuality` | `number` | `0.8` | Quality for lossy formats (0–1) |
|
|
213
234
|
| `maxImageWidth` | `number` | `1024` | Max width in px; downscales and rescales bboxes if larger |
|
|
214
235
|
| `interactiveSelector` | `string` | see below | CSS selector for the interactive element set |
|
|
@@ -219,7 +240,17 @@ Defaults:
|
|
|
219
240
|
- **interactive**: `a[href], button, input, select, textarea, [role="button"|"link"|"tab"|"menuitem"|"checkbox"|"radio"], [tabindex]:not([tabindex="-1"]), summary, [contenteditable="true"]`
|
|
220
241
|
- **semantic**: `h1–h6, p, li, img[alt], nav, main, article, section, header, footer, label, td, th, figcaption, blockquote, legend`
|
|
221
242
|
|
|
222
|
-
Both per-call options (`opts.imageFormat`, etc.) and constructor options are supported; per-call wins.
|
|
243
|
+
Both per-call options (`opts.include`, `opts.imageFormat`, etc.) and constructor options are supported; per-call wins.
|
|
244
|
+
|
|
245
|
+
The image is the most expensive part of `toPrompt()` to produce (canvas draw + data-URL serialization), so the default skips it. Add `'image'` to `include` when the task actually uses vision:
|
|
246
|
+
|
|
247
|
+
```js
|
|
248
|
+
// Vision-dependent task (chart content, layout QA, visual diff)
|
|
249
|
+
await result.toPrompt({ include: ['image', 'elements', 'prompt'] });
|
|
250
|
+
|
|
251
|
+
// Pure structured agent loop (cheapest)
|
|
252
|
+
await result.toPrompt({ include: ['prompt'] });
|
|
253
|
+
```
|
|
223
254
|
|
|
224
255
|
---
|
|
225
256
|
|
package/package.json
CHANGED
package/prompt-export.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* promptExport – Official SnapDOM Plugin
|
|
3
|
-
* Produces an LLM-ready package: annotated screenshot + structured element
|
|
3
|
+
* Produces an LLM-ready package: annotated screenshot + structured element
|
|
4
|
+
* map + prompt text. Tuned for vision-capable LLMs reading the image + map
|
|
5
|
+
* together (Set-of-Mark pattern).
|
|
4
6
|
*
|
|
5
7
|
* Usage:
|
|
6
8
|
* import { promptExport } from '@zumer/snapdom-plugins/prompt-export';
|
|
@@ -9,12 +11,19 @@
|
|
|
9
11
|
*
|
|
10
12
|
* @param {Object} [options]
|
|
11
13
|
* @param {boolean} [options.annotate=true] - Overlay numbered badges on interactive elements
|
|
12
|
-
* @param {string} [options.imageFormat='png']
|
|
13
|
-
* @param {number} [options.imageQuality=0.8]
|
|
14
|
-
* @param {number} [options.maxImageWidth=1024]
|
|
15
|
-
* @param {string} [options.interactiveSelector]
|
|
16
|
-
* @param {string} [options.semanticSelector]
|
|
17
|
-
* @param {Object} [options.labelStyle={}]
|
|
14
|
+
* @param {string} [options.imageFormat='png'] - Output image format ('png'|'jpg'|'webp')
|
|
15
|
+
* @param {number} [options.imageQuality=0.8] - Quality for lossy formats (0..1)
|
|
16
|
+
* @param {number} [options.maxImageWidth=1024] - Max width in px (downscales if larger)
|
|
17
|
+
* @param {string} [options.interactiveSelector] - Custom CSS selector for interactive elements
|
|
18
|
+
* @param {string} [options.semanticSelector] - Custom CSS selector for semantic elements
|
|
19
|
+
* @param {Object} [options.labelStyle={}] - Override styles for annotation badges
|
|
20
|
+
* @param {'compact'|'verbose'} [options.promptMode='compact'] - Prompt text verbosity
|
|
21
|
+
* @param {boolean} [options.includeCoords=true] - Include bbox in the prompt text
|
|
22
|
+
* @param {string[]} [options.include] - Which fields to return. Default
|
|
23
|
+
* ['elements', 'prompt']. For vision-dependent tasks (chart content, layout QA,
|
|
24
|
+
* canvas) pass ['image', 'elements', 'prompt'] or add 'image' to the array. For
|
|
25
|
+
* text-only agent prompts pass ['prompt'] (cheapest — skips canvas draw entirely).
|
|
26
|
+
* Accepted values: 'image', 'elements', 'prompt'.
|
|
18
27
|
* @returns {Object} SnapDOM plugin
|
|
19
28
|
*/
|
|
20
29
|
|
|
@@ -27,6 +36,39 @@ const DEFAULT_SEMANTIC =
|
|
|
27
36
|
'h1, h2, h3, h4, h5, h6, p, li, img[alt], nav, main, article, section, ' +
|
|
28
37
|
'header, footer, label, td, th, figcaption, blockquote, legend';
|
|
29
38
|
|
|
39
|
+
const COLLECTED_ATTRS = [
|
|
40
|
+
'role', 'aria-label', 'aria-expanded', 'aria-checked', 'aria-disabled',
|
|
41
|
+
'alt', 'href', 'placeholder', 'name', 'type', 'value', 'title', 'disabled',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const VISUAL_FIELDS = [
|
|
45
|
+
'display', 'visibility', 'opacity',
|
|
46
|
+
'color', 'backgroundColor',
|
|
47
|
+
'fontSize', 'fontWeight',
|
|
48
|
+
'cursor', 'overflow',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// Common computed-style values that carry no information. Keeping the
|
|
52
|
+
// `styles` object small is the difference between a useful LLM input and
|
|
53
|
+
// token bloat on every element.
|
|
54
|
+
const VISUAL_SKIP = new Set(['initial', 'normal', 'visible', 'auto', 'static', '0']);
|
|
55
|
+
function isDefaultStyleValue(prop, value) {
|
|
56
|
+
if (!value) return true;
|
|
57
|
+
if (VISUAL_SKIP.has(value)) return true;
|
|
58
|
+
if (prop === 'cursor' && value === 'none') return true;
|
|
59
|
+
if (prop === 'color' && value === 'rgb(0, 0, 0)') return true;
|
|
60
|
+
if (prop === 'backgroundColor' && value === 'rgba(0, 0, 0, 0)') return true;
|
|
61
|
+
if (prop === 'fontWeight' && (value === '400' || value === 'normal')) return true;
|
|
62
|
+
if (prop === 'opacity' && value === '1') return true;
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Default omits 'image'. Benchmarking showed the text + JSON map is enough
|
|
67
|
+
// to answer most UI-inspection questions and uses ~14× fewer tokens. Pass
|
|
68
|
+
// `include: ['image', 'elements', 'prompt']` explicitly when the task truly
|
|
69
|
+
// depends on vision (charts, canvas content, layout QA).
|
|
70
|
+
const DEFAULT_INCLUDE = ['elements', 'prompt'];
|
|
71
|
+
|
|
30
72
|
export function promptExport(options = {}) {
|
|
31
73
|
const {
|
|
32
74
|
annotate = true,
|
|
@@ -36,6 +78,9 @@ export function promptExport(options = {}) {
|
|
|
36
78
|
interactiveSelector = DEFAULT_INTERACTIVE,
|
|
37
79
|
semanticSelector = DEFAULT_SEMANTIC,
|
|
38
80
|
labelStyle = {},
|
|
81
|
+
promptMode = 'compact',
|
|
82
|
+
includeCoords = true,
|
|
83
|
+
include = DEFAULT_INCLUDE,
|
|
39
84
|
} = options;
|
|
40
85
|
|
|
41
86
|
return {
|
|
@@ -43,10 +88,8 @@ export function promptExport(options = {}) {
|
|
|
43
88
|
|
|
44
89
|
afterClone(ctx) {
|
|
45
90
|
const meta = extractMetadata(ctx.element, interactiveSelector, semanticSelector);
|
|
46
|
-
//
|
|
47
|
-
// prompt()
|
|
48
|
-
// on both so (a) standalone tests that pass a minimal ctx still see it
|
|
49
|
-
// and (b) the prompt() call can read it through the shared options ref.
|
|
91
|
+
// snapdom spreads a fresh ctx for the export phase from ctx.options,
|
|
92
|
+
// so write to both so the prompt() call below can read it.
|
|
50
93
|
ctx.__promptMetadata = meta;
|
|
51
94
|
if (ctx.options) ctx.options.__promptMetadata = meta;
|
|
52
95
|
|
|
@@ -59,49 +102,59 @@ export function promptExport(options = {}) {
|
|
|
59
102
|
return {
|
|
60
103
|
prompt: async (ctx, opts = {}) => {
|
|
61
104
|
const meta = ctx.__promptMetadata;
|
|
105
|
+
const wantSet = new Set(opts.include || include || DEFAULT_INCLUDE);
|
|
106
|
+
const wantImage = wantSet.has('image');
|
|
107
|
+
const wantElements = wantSet.has('elements');
|
|
108
|
+
const wantPrompt = wantSet.has('prompt');
|
|
109
|
+
|
|
62
110
|
if (!meta || !meta.elements.length) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
};
|
|
111
|
+
const empty = { dimensions: { width: 0, height: 0 } };
|
|
112
|
+
if (wantImage) empty.image = ctx.export.url;
|
|
113
|
+
if (wantElements) empty.elements = [];
|
|
114
|
+
if (wantPrompt) empty.prompt = '';
|
|
115
|
+
return empty;
|
|
69
116
|
}
|
|
70
117
|
|
|
71
118
|
const format = opts.imageFormat || imageFormat;
|
|
72
119
|
const quality = opts.imageQuality || imageQuality;
|
|
73
120
|
const maxWidth = opts.maxImageWidth || maxImageWidth;
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
img.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
121
|
+
const mode = opts.promptMode || promptMode;
|
|
122
|
+
const withCoords = opts.includeCoords !== undefined ? opts.includeCoords : includeCoords;
|
|
123
|
+
|
|
124
|
+
// Only load + rasterize the SVG when the caller actually wants the
|
|
125
|
+
// image. Skipping saves the img decode + canvas draw + toDataURL —
|
|
126
|
+
// the most expensive steps of this export.
|
|
127
|
+
let w, h, dataURL;
|
|
128
|
+
if (wantImage) {
|
|
129
|
+
const img = new Image();
|
|
130
|
+
img.src = ctx.export.url;
|
|
131
|
+
await new Promise((res, rej) => { img.onload = res; img.onerror = rej; });
|
|
132
|
+
const ratio = img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1;
|
|
133
|
+
w = Math.round(img.naturalWidth * ratio);
|
|
134
|
+
h = Math.round(img.naturalHeight * ratio);
|
|
135
|
+
const canvas = document.createElement('canvas');
|
|
136
|
+
canvas.width = w;
|
|
137
|
+
canvas.height = h;
|
|
138
|
+
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
|
139
|
+
const mime =
|
|
140
|
+
format === 'jpg' || format === 'jpeg' ? 'image/jpeg'
|
|
141
|
+
: format === 'webp' ? 'image/webp'
|
|
142
|
+
: 'image/png';
|
|
143
|
+
dataURL = canvas.toDataURL(mime, quality);
|
|
144
|
+
} else {
|
|
145
|
+
// No image — scale bboxes to the same target width the image would
|
|
146
|
+
// have used, so downstream callers can still render the map over
|
|
147
|
+
// a separately-rendered screenshot at the same scale.
|
|
148
|
+
const sourceW = meta.dimensions.width || 1;
|
|
149
|
+
const ratio = sourceW > maxWidth ? maxWidth / sourceW : 1;
|
|
150
|
+
w = Math.round(sourceW * ratio);
|
|
151
|
+
h = Math.round(meta.dimensions.height * ratio);
|
|
152
|
+
}
|
|
100
153
|
|
|
101
154
|
const sx = w / (meta.dimensions.width || 1);
|
|
102
155
|
const sy = h / (meta.dimensions.height || 1);
|
|
103
156
|
|
|
104
|
-
const
|
|
157
|
+
const scaledElements = meta.elements.map((el) => ({
|
|
105
158
|
...el,
|
|
106
159
|
bbox: {
|
|
107
160
|
x: Math.round(el.bbox.x * sx),
|
|
@@ -111,18 +164,152 @@ export function promptExport(options = {}) {
|
|
|
111
164
|
},
|
|
112
165
|
}));
|
|
113
166
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
167
|
+
const emitCoords = mode === 'verbose' ? true : (withCoords && !annotate);
|
|
168
|
+
const promptText = (wantPrompt)
|
|
169
|
+
? (mode === 'verbose'
|
|
170
|
+
? formatPromptVerbose(scaledElements, { width: w, height: h }, emitCoords)
|
|
171
|
+
: formatPromptCompact(scaledElements, { width: w, height: h }, emitCoords))
|
|
172
|
+
: null;
|
|
173
|
+
|
|
174
|
+
const out = { dimensions: { width: w, height: h } };
|
|
175
|
+
if (wantImage) out.image = dataURL;
|
|
176
|
+
if (wantElements) out.elements = scaledElements;
|
|
177
|
+
if (wantPrompt) out.prompt = promptText;
|
|
178
|
+
return out;
|
|
120
179
|
},
|
|
121
180
|
};
|
|
122
181
|
},
|
|
123
182
|
};
|
|
124
183
|
}
|
|
125
184
|
|
|
185
|
+
/* ── Accessible name ──────────────────────────── */
|
|
186
|
+
|
|
187
|
+
function truncate(str, max) {
|
|
188
|
+
if (!str) return '';
|
|
189
|
+
if (str.length <= max) return str;
|
|
190
|
+
return str.slice(0, max - 1) + '…';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Compute the element's accessible name following a simplified WAI-ARIA order.
|
|
195
|
+
* This is what an LLM agent reads first to know what the element IS.
|
|
196
|
+
*/
|
|
197
|
+
function accessibleName(el) {
|
|
198
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
199
|
+
if (ariaLabel && ariaLabel.trim()) return ariaLabel.trim();
|
|
200
|
+
|
|
201
|
+
const labelledBy = el.getAttribute('aria-labelledby');
|
|
202
|
+
if (labelledBy) {
|
|
203
|
+
const root = el.getRootNode();
|
|
204
|
+
const getById = (id) =>
|
|
205
|
+
root && typeof root.getElementById === 'function'
|
|
206
|
+
? root.getElementById(id)
|
|
207
|
+
: document.getElementById(id);
|
|
208
|
+
const parts = labelledBy.trim().split(/\s+/)
|
|
209
|
+
.map((id) => {
|
|
210
|
+
const ref = getById(id);
|
|
211
|
+
return ref ? (ref.textContent || '').trim() : '';
|
|
212
|
+
})
|
|
213
|
+
.filter(Boolean);
|
|
214
|
+
if (parts.length) return parts.join(' ');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (el.tagName === 'IMG') {
|
|
218
|
+
const alt = el.getAttribute('alt');
|
|
219
|
+
if (alt && alt.trim()) return alt.trim();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const title = el.getAttribute('title');
|
|
223
|
+
if (title && title.trim()) return title.trim();
|
|
224
|
+
|
|
225
|
+
if (el.labels && el.labels[0]) {
|
|
226
|
+
const t = (el.labels[0].textContent || '').trim();
|
|
227
|
+
if (t) return t;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const text = (el.textContent || '').trim();
|
|
231
|
+
return text ? truncate(text, 40) : '';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* ── DOM state ────────────────────────────────── */
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Capture real runtime state (not just ARIA attributes). This is what
|
|
238
|
+
* separates a useful map from a static screenshot annotation: an agent can
|
|
239
|
+
* see "checkbox is unchecked / select has value=X / details is closed".
|
|
240
|
+
*/
|
|
241
|
+
function computeState(el) {
|
|
242
|
+
const state = {};
|
|
243
|
+
try {
|
|
244
|
+
if (el.matches(':checked')) state.checked = true;
|
|
245
|
+
if (el.matches(':disabled')) state.disabled = true;
|
|
246
|
+
if (el.matches(':focus')) state.focus = true;
|
|
247
|
+
} catch { /* some selectors may fail on exotic nodes */ }
|
|
248
|
+
|
|
249
|
+
const tag = el.tagName;
|
|
250
|
+
if (tag === 'INPUT') {
|
|
251
|
+
// Checkbox/radio: `value` defaults to "on" when no explicit value attr
|
|
252
|
+
// is set — noise. The meaningful signal is `checked`. Skip value here.
|
|
253
|
+
const type = (el.type || 'text').toLowerCase();
|
|
254
|
+
if (type !== 'checkbox' && type !== 'radio' && el.value) {
|
|
255
|
+
state.value = el.value;
|
|
256
|
+
}
|
|
257
|
+
} else if (tag === 'TEXTAREA') {
|
|
258
|
+
if (el.value) state.value = el.value;
|
|
259
|
+
} else if (tag === 'SELECT') {
|
|
260
|
+
state.value = el.value;
|
|
261
|
+
const opt = el.options && el.options[el.selectedIndex];
|
|
262
|
+
if (opt) state.selectedText = opt.text || '';
|
|
263
|
+
} else if (tag === 'DETAILS') {
|
|
264
|
+
state.open = !!el.open;
|
|
265
|
+
} else if (el.hasAttribute && el.hasAttribute('open')) {
|
|
266
|
+
state.open = true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return Object.keys(state).length ? state : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* ── Visual styles ────────────────────────────── */
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Pull a small set of visually-meaningful computed styles. Reads from the
|
|
276
|
+
* live element (the clone uses class-based styling so its inline style is
|
|
277
|
+
* empty and a detached `getComputedStyle(cloneNode)` returns initial values).
|
|
278
|
+
* The computed style of the original is what snapdom used to build the
|
|
279
|
+
* capture, so the result matches what the screenshot shows.
|
|
280
|
+
*/
|
|
281
|
+
function computeVisualStyles(el) {
|
|
282
|
+
let cs;
|
|
283
|
+
try { cs = getComputedStyle(el); } catch { return null; }
|
|
284
|
+
if (!cs) return null;
|
|
285
|
+
const out = {};
|
|
286
|
+
for (const prop of VISUAL_FIELDS) {
|
|
287
|
+
const v = cs[prop];
|
|
288
|
+
if (isDefaultStyleValue(prop, v)) continue;
|
|
289
|
+
out[prop] = v;
|
|
290
|
+
}
|
|
291
|
+
return Object.keys(out).length ? out : null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* ── Covered detection ────────────────────────── */
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* True when another element is painted on top of the center of this one.
|
|
298
|
+
* An agent that knows a button is covered by a modal won't try to click it.
|
|
299
|
+
*/
|
|
300
|
+
function isCovered(el, rect) {
|
|
301
|
+
if (!rect.width || !rect.height) return false;
|
|
302
|
+
const cx = rect.left + rect.width / 2;
|
|
303
|
+
const cy = rect.top + rect.height / 2;
|
|
304
|
+
if (cx < 0 || cy < 0) return false;
|
|
305
|
+
const doc = el.ownerDocument || document;
|
|
306
|
+
if (!doc.elementFromPoint) return false;
|
|
307
|
+
const top = doc.elementFromPoint(cx, cy);
|
|
308
|
+
if (!top || top === el) return false;
|
|
309
|
+
if (el.contains && el.contains(top)) return false;
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
126
313
|
/* ── Metadata extraction ──────────────────────── */
|
|
127
314
|
|
|
128
315
|
function extractMetadata(element, interactiveSelector, semanticSelector) {
|
|
@@ -156,11 +343,6 @@ function extractMetadata(element, interactiveSelector, semanticSelector) {
|
|
|
156
343
|
};
|
|
157
344
|
}
|
|
158
345
|
|
|
159
|
-
const COLLECTED_ATTRS = [
|
|
160
|
-
'role', 'aria-label', 'aria-expanded', 'aria-checked', 'aria-disabled',
|
|
161
|
-
'alt', 'href', 'placeholder', 'name', 'type', 'value', 'title', 'disabled',
|
|
162
|
-
];
|
|
163
|
-
|
|
164
346
|
function buildEntry(el, rootRect, id, type) {
|
|
165
347
|
const rect = el.getBoundingClientRect();
|
|
166
348
|
const bbox = {
|
|
@@ -173,15 +355,58 @@ function buildEntry(el, rootRect, id, type) {
|
|
|
173
355
|
if (bbox.width <= 0 && bbox.height <= 0) return null;
|
|
174
356
|
|
|
175
357
|
const tag = el.tagName.toLowerCase();
|
|
176
|
-
const
|
|
358
|
+
const maxText = type === 'interactive' ? 200 : 120;
|
|
359
|
+
const text = (el.textContent || '').trim().slice(0, maxText);
|
|
177
360
|
|
|
178
361
|
const attributes = {};
|
|
179
362
|
for (const attr of COLLECTED_ATTRS) {
|
|
180
363
|
const val = el.getAttribute(attr);
|
|
181
|
-
if (val
|
|
364
|
+
if (val == null || val === '' || val === 'false') continue;
|
|
365
|
+
attributes[attr] = val;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// For <img>, replace the (potentially long, cache-busted) src with just the
|
|
369
|
+
// filename — "logo.svg" is more useful to an LLM than the full URL.
|
|
370
|
+
if (tag === 'img' && el.src) {
|
|
371
|
+
try {
|
|
372
|
+
const base = (el.ownerDocument && el.ownerDocument.location)
|
|
373
|
+
? el.ownerDocument.location.href
|
|
374
|
+
: (typeof location !== 'undefined' ? location.href : undefined);
|
|
375
|
+
const url = base ? new URL(el.src, base) : new URL(el.src);
|
|
376
|
+
attributes.src = url.pathname.split('/').pop() || el.src;
|
|
377
|
+
} catch {
|
|
378
|
+
attributes.src = el.src;
|
|
379
|
+
}
|
|
182
380
|
}
|
|
183
381
|
|
|
184
|
-
|
|
382
|
+
const entry = {
|
|
383
|
+
id,
|
|
384
|
+
tag,
|
|
385
|
+
type,
|
|
386
|
+
name: accessibleName(el),
|
|
387
|
+
text,
|
|
388
|
+
bbox,
|
|
389
|
+
attributes,
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const styles = computeVisualStyles(el);
|
|
393
|
+
if (styles) entry.styles = styles;
|
|
394
|
+
|
|
395
|
+
if (type === 'interactive') {
|
|
396
|
+
const state = computeState(el);
|
|
397
|
+
if (state) {
|
|
398
|
+
// Drop state.value when it just echoes attributes.value — no
|
|
399
|
+
// divergence between the initial HTML attribute and the current
|
|
400
|
+
// property, nothing for the LLM to learn from the repeat.
|
|
401
|
+
if (state.value !== undefined && state.value === attributes.value) {
|
|
402
|
+
delete state.value;
|
|
403
|
+
}
|
|
404
|
+
if (Object.keys(state).length) entry.state = state;
|
|
405
|
+
}
|
|
406
|
+
if (isCovered(el, rect)) entry.covered = true;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return entry;
|
|
185
410
|
}
|
|
186
411
|
|
|
187
412
|
/* ── Visual annotations ───────────────────────── */
|
|
@@ -207,10 +432,14 @@ function addAnnotations(clone, elements, customStyle) {
|
|
|
207
432
|
const badge = document.createElement('span');
|
|
208
433
|
badge.textContent = String(el.id);
|
|
209
434
|
badge.setAttribute('data-snap-prompt-label', String(el.id));
|
|
435
|
+
// Center the badge on the element's bbox, not on its top-left corner:
|
|
436
|
+
// `translate(-50%, -50%)` offsets from the anchor point.
|
|
437
|
+
const cx = el.bbox.x + el.bbox.width / 2;
|
|
438
|
+
const cy = el.bbox.y + el.bbox.height / 2;
|
|
210
439
|
Object.assign(badge.style, {
|
|
211
440
|
position: 'absolute',
|
|
212
|
-
left: `${
|
|
213
|
-
top: `${
|
|
441
|
+
left: `${cx}px`,
|
|
442
|
+
top: `${cy}px`,
|
|
214
443
|
transform: 'translate(-50%, -50%)',
|
|
215
444
|
minWidth: '18px',
|
|
216
445
|
height: '18px',
|
|
@@ -234,11 +463,56 @@ function addAnnotations(clone, elements, customStyle) {
|
|
|
234
463
|
clone.appendChild(overlay);
|
|
235
464
|
}
|
|
236
465
|
|
|
237
|
-
/* ── Prompt text
|
|
466
|
+
/* ── Prompt text formatters ───────────────────── */
|
|
467
|
+
|
|
468
|
+
function stateToStr(state) {
|
|
469
|
+
if (!state) return '';
|
|
470
|
+
const flags = [];
|
|
471
|
+
const pairs = [];
|
|
472
|
+
for (const k of Object.keys(state)) {
|
|
473
|
+
const v = state[k];
|
|
474
|
+
if (v === true) flags.push(k);
|
|
475
|
+
else pairs.push(`${k}=${JSON.stringify(v)}`);
|
|
476
|
+
}
|
|
477
|
+
if (!flags.length && !pairs.length) return '';
|
|
478
|
+
return ` {${[...flags, ...pairs].join(', ')}}`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function coordsToStr(bbox) {
|
|
482
|
+
return ` (${bbox.x},${bbox.y} ${bbox.width}×${bbox.height})`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function formatPromptCompact(elements, dimensions, withCoords) {
|
|
486
|
+
const lines = [`Screenshot (${dimensions.width}×${dimensions.height}px).`];
|
|
487
|
+
|
|
488
|
+
const interactive = elements.filter((e) => e.type === 'interactive');
|
|
489
|
+
const semantic = elements.filter((e) => e.type === 'semantic');
|
|
490
|
+
|
|
491
|
+
if (interactive.length) {
|
|
492
|
+
lines.push('', 'Interactive:');
|
|
493
|
+
for (const el of interactive) {
|
|
494
|
+
const name = el.name ? ` "${truncate(el.name, 60)}"` : '';
|
|
495
|
+
const st = stateToStr(el.state);
|
|
496
|
+
const cov = el.covered ? ' (covered)' : '';
|
|
497
|
+
const coords = withCoords ? coordsToStr(el.bbox) : '';
|
|
498
|
+
lines.push(` [${el.id}] ${el.tag}${name}${st}${cov}${coords}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (semantic.length) {
|
|
503
|
+
lines.push('', 'Semantic:');
|
|
504
|
+
for (const el of semantic) {
|
|
505
|
+
const name = el.name ? ` "${truncate(el.name, 60)}"` : '';
|
|
506
|
+
lines.push(` [${el.id}] ${el.tag}${name}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
238
509
|
|
|
239
|
-
|
|
510
|
+
return lines.join('\n');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function formatPromptVerbose(elements, dimensions, withCoords) {
|
|
240
514
|
const lines = [
|
|
241
|
-
`Screenshot of a web page (${dimensions.width}
|
|
515
|
+
`Screenshot of a web page (${dimensions.width}×${dimensions.height}px).`,
|
|
242
516
|
'',
|
|
243
517
|
];
|
|
244
518
|
|
|
@@ -248,13 +522,13 @@ function formatPromptText(elements, dimensions) {
|
|
|
248
522
|
if (interactive.length) {
|
|
249
523
|
lines.push('Interactive elements:');
|
|
250
524
|
for (const el of interactive) {
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
);
|
|
254
|
-
const text = el.text ? ` "${truncate(el.text, 60)}"` : '';
|
|
255
|
-
const pos = `(${el.bbox.x},${el.bbox.y} ${el.bbox.width}\u00d7${el.bbox.height})`;
|
|
525
|
+
const name = el.name ? ` "${truncate(el.name, 80)}"` : '';
|
|
526
|
+
const attrParts = Object.entries(el.attributes).map(([k, v]) => `${k}="${v}"`);
|
|
256
527
|
const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
|
|
257
|
-
|
|
528
|
+
const pos = withCoords ? coordsToStr(el.bbox) : '';
|
|
529
|
+
const st = stateToStr(el.state);
|
|
530
|
+
const cov = el.covered ? ' (covered)' : '';
|
|
531
|
+
lines.push(` [${el.id}] <${el.tag}>${name}${pos}${attrs}${st}${cov}`);
|
|
258
532
|
}
|
|
259
533
|
lines.push('');
|
|
260
534
|
}
|
|
@@ -262,20 +536,15 @@ function formatPromptText(elements, dimensions) {
|
|
|
262
536
|
if (semantic.length) {
|
|
263
537
|
lines.push('Semantic structure:');
|
|
264
538
|
for (const el of semantic) {
|
|
265
|
-
const
|
|
539
|
+
const name = el.name ? ` "${truncate(el.name, 80)}"` : '';
|
|
266
540
|
const attrParts = [];
|
|
267
541
|
if (el.attributes.alt) attrParts.push(`alt="${el.attributes.alt}"`);
|
|
268
542
|
if (el.attributes.role) attrParts.push(`role="${el.attributes.role}"`);
|
|
269
543
|
const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
|
|
270
|
-
lines.push(` [${el.id}] <${el.tag}>${
|
|
544
|
+
lines.push(` [${el.id}] <${el.tag}>${name}${attrs}`);
|
|
271
545
|
}
|
|
272
546
|
lines.push('');
|
|
273
547
|
}
|
|
274
548
|
|
|
275
549
|
return lines.join('\n');
|
|
276
550
|
}
|
|
277
|
-
|
|
278
|
-
function truncate(str, max) {
|
|
279
|
-
if (str.length <= max) return str;
|
|
280
|
-
return str.slice(0, max - 1) + '\u2026';
|
|
281
|
-
}
|