@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.
Files changed (3) hide show
  1. package/README.md +41 -10
  2. package/package.json +1 -1
  3. 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: an annotated screenshot, a structured element map with bounding boxes, and a pre-formatted text description. Useful for vision-language models, browser-agent pipelines, visual QA, and any workflow that pairs a capture with structured metadata.
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
- const { image, elements, dimensions, prompt } = await result.toPrompt();
181
+ // Default: no image, just the structured map + prompt text (cheapest)
182
+ const { elements, prompt, dimensions } = await result.toPrompt();
182
183
  ```
183
184
 
184
- The returned object:
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
- | `image` | `string` | Data URL of the (optionally annotated) screenshot |
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({ maxImageWidth: 1024 });
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
- | `annotate` | `boolean` | `true` | Overlay numbered badges on interactive elements |
211
- | `imageFormat` | `'png' \| 'jpg' \| 'webp'` | `'png'` | Output image format |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zumer/snapdom-plugins",
3
- "version": "1.0.3",
3
+ "version": "1.2.0",
4
4
  "description": "Official plugins for SnapDOM",
5
5
  "type": "module",
6
6
  "exports": {
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 map + prompt text.
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'] - Output image format ('png'|'jpg'|'webp')
13
- * @param {number} [options.imageQuality=0.8] - Quality for lossy formats (0..1)
14
- * @param {number} [options.maxImageWidth=1024] - Max width in px (downscales if larger)
15
- * @param {string} [options.interactiveSelector] - Custom CSS selector for interactive elements
16
- * @param {string} [options.semanticSelector] - Custom CSS selector for semantic elements
17
- * @param {Object} [options.labelStyle={}] - Override styles for annotation badges
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
- // The ctx passed to afterClone is NOT the same object passed to the
47
- // prompt() export later — snapdom spreads from ctx.options there. Stash
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
- return {
64
- image: ctx.export.url,
65
- elements: [],
66
- dimensions: { width: 0, height: 0 },
67
- prompt: '',
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 img = new Image();
76
- img.src = ctx.export.url;
77
- await new Promise((res, rej) => {
78
- img.onload = res;
79
- img.onerror = rej;
80
- });
81
-
82
- const ratio =
83
- img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1;
84
- const w = Math.round(img.naturalWidth * ratio);
85
- const h = Math.round(img.naturalHeight * ratio);
86
-
87
- const canvas = document.createElement('canvas');
88
- canvas.width = w;
89
- canvas.height = h;
90
- const c2d = canvas.getContext('2d');
91
- c2d.drawImage(img, 0, 0, w, h);
92
-
93
- const mime =
94
- format === 'jpg' || format === 'jpeg'
95
- ? 'image/jpeg'
96
- : format === 'webp'
97
- ? 'image/webp'
98
- : 'image/png';
99
- const dataURL = canvas.toDataURL(mime, quality);
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 elements = meta.elements.map((el) => ({
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
- return {
115
- image: dataURL,
116
- elements,
117
- dimensions: { width: w, height: h },
118
- prompt: formatPromptText(elements, { width: w, height: h }),
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 text = (el.textContent || '').trim().slice(0, 200);
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 != null) attributes[attr] = 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
- return { id, tag, type, text, bbox, attributes };
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: `${el.bbox.x}px`,
213
- top: `${el.bbox.y}px`,
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 formatter ────────────────────── */
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
- function formatPromptText(elements, dimensions) {
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}\u00d7${dimensions.height}px).`,
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 attrParts = Object.entries(el.attributes).map(
252
- ([k, v]) => `${k}="${v}"`
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
- lines.push(` [${el.id}] <${el.tag}>${text} ${pos}${attrs}`);
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 text = el.text ? ` "${truncate(el.text, 80)}"` : '';
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}>${text}${attrs}`);
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
- }