@zumer/snapdom-plugins 1.0.3 → 1.1.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 (2) hide show
  1. package/package.json +1 -1
  2. package/prompt-export.js +293 -47
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zumer/snapdom-plugins",
3
- "version": "1.0.3",
3
+ "version": "1.1.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,14 @@
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
18
22
  * @returns {Object} SnapDOM plugin
19
23
  */
20
24
 
@@ -27,6 +31,33 @@ const DEFAULT_SEMANTIC =
27
31
  'h1, h2, h3, h4, h5, h6, p, li, img[alt], nav, main, article, section, ' +
28
32
  'header, footer, label, td, th, figcaption, blockquote, legend';
29
33
 
34
+ const COLLECTED_ATTRS = [
35
+ 'role', 'aria-label', 'aria-expanded', 'aria-checked', 'aria-disabled',
36
+ 'alt', 'href', 'placeholder', 'name', 'type', 'value', 'title', 'disabled',
37
+ ];
38
+
39
+ const VISUAL_FIELDS = [
40
+ 'display', 'visibility', 'opacity',
41
+ 'color', 'backgroundColor',
42
+ 'fontSize', 'fontWeight',
43
+ 'cursor', 'overflow',
44
+ ];
45
+
46
+ // Common computed-style values that carry no information. Keeping the
47
+ // `styles` object small is the difference between a useful LLM input and
48
+ // token bloat on every element.
49
+ const VISUAL_SKIP = new Set(['initial', 'normal', 'visible', 'auto', 'static', '0']);
50
+ function isDefaultStyleValue(prop, value) {
51
+ if (!value) return true;
52
+ if (VISUAL_SKIP.has(value)) return true;
53
+ if (prop === 'cursor' && value === 'none') return true;
54
+ if (prop === 'color' && value === 'rgb(0, 0, 0)') return true;
55
+ if (prop === 'backgroundColor' && value === 'rgba(0, 0, 0, 0)') return true;
56
+ if (prop === 'fontWeight' && (value === '400' || value === 'normal')) return true;
57
+ if (prop === 'opacity' && value === '1') return true;
58
+ return false;
59
+ }
60
+
30
61
  export function promptExport(options = {}) {
31
62
  const {
32
63
  annotate = true,
@@ -36,6 +67,8 @@ export function promptExport(options = {}) {
36
67
  interactiveSelector = DEFAULT_INTERACTIVE,
37
68
  semanticSelector = DEFAULT_SEMANTIC,
38
69
  labelStyle = {},
70
+ promptMode = 'compact',
71
+ includeCoords = true,
39
72
  } = options;
40
73
 
41
74
  return {
@@ -43,10 +76,8 @@ export function promptExport(options = {}) {
43
76
 
44
77
  afterClone(ctx) {
45
78
  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.
79
+ // snapdom spreads a fresh ctx for the export phase from ctx.options,
80
+ // so write to both so the prompt() call below can read it.
50
81
  ctx.__promptMetadata = meta;
51
82
  if (ctx.options) ctx.options.__promptMetadata = meta;
52
83
 
@@ -71,6 +102,8 @@ export function promptExport(options = {}) {
71
102
  const format = opts.imageFormat || imageFormat;
72
103
  const quality = opts.imageQuality || imageQuality;
73
104
  const maxWidth = opts.maxImageWidth || maxImageWidth;
105
+ const mode = opts.promptMode || promptMode;
106
+ const withCoords = opts.includeCoords !== undefined ? opts.includeCoords : includeCoords;
74
107
 
75
108
  const img = new Image();
76
109
  img.src = ctx.export.url;
@@ -79,23 +112,19 @@ export function promptExport(options = {}) {
79
112
  img.onerror = rej;
80
113
  });
81
114
 
82
- const ratio =
83
- img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1;
115
+ const ratio = img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1;
84
116
  const w = Math.round(img.naturalWidth * ratio);
85
117
  const h = Math.round(img.naturalHeight * ratio);
86
118
 
87
119
  const canvas = document.createElement('canvas');
88
120
  canvas.width = w;
89
121
  canvas.height = h;
90
- const c2d = canvas.getContext('2d');
91
- c2d.drawImage(img, 0, 0, w, h);
122
+ canvas.getContext('2d').drawImage(img, 0, 0, w, h);
92
123
 
93
124
  const mime =
94
- format === 'jpg' || format === 'jpeg'
95
- ? 'image/jpeg'
96
- : format === 'webp'
97
- ? 'image/webp'
98
- : 'image/png';
125
+ format === 'jpg' || format === 'jpeg' ? 'image/jpeg'
126
+ : format === 'webp' ? 'image/webp'
127
+ : 'image/png';
99
128
  const dataURL = canvas.toDataURL(mime, quality);
100
129
 
101
130
  const sx = w / (meta.dimensions.width || 1);
@@ -111,11 +140,18 @@ export function promptExport(options = {}) {
111
140
  },
112
141
  }));
113
142
 
143
+ // In compact + annotate, badges already encode positions on the
144
+ // image itself, so repeating coords in the prompt text is noise.
145
+ const emitCoords = mode === 'verbose' ? true : (withCoords && !annotate);
146
+ const prompt = mode === 'verbose'
147
+ ? formatPromptVerbose(elements, { width: w, height: h }, emitCoords)
148
+ : formatPromptCompact(elements, { width: w, height: h }, emitCoords);
149
+
114
150
  return {
115
151
  image: dataURL,
116
152
  elements,
117
153
  dimensions: { width: w, height: h },
118
- prompt: formatPromptText(elements, { width: w, height: h }),
154
+ prompt,
119
155
  };
120
156
  },
121
157
  };
@@ -123,6 +159,134 @@ export function promptExport(options = {}) {
123
159
  };
124
160
  }
125
161
 
162
+ /* ── Accessible name ──────────────────────────── */
163
+
164
+ function truncate(str, max) {
165
+ if (!str) return '';
166
+ if (str.length <= max) return str;
167
+ return str.slice(0, max - 1) + '…';
168
+ }
169
+
170
+ /**
171
+ * Compute the element's accessible name following a simplified WAI-ARIA order.
172
+ * This is what an LLM agent reads first to know what the element IS.
173
+ */
174
+ function accessibleName(el) {
175
+ const ariaLabel = el.getAttribute('aria-label');
176
+ if (ariaLabel && ariaLabel.trim()) return ariaLabel.trim();
177
+
178
+ const labelledBy = el.getAttribute('aria-labelledby');
179
+ if (labelledBy) {
180
+ const root = el.getRootNode();
181
+ const getById = (id) =>
182
+ root && typeof root.getElementById === 'function'
183
+ ? root.getElementById(id)
184
+ : document.getElementById(id);
185
+ const parts = labelledBy.trim().split(/\s+/)
186
+ .map((id) => {
187
+ const ref = getById(id);
188
+ return ref ? (ref.textContent || '').trim() : '';
189
+ })
190
+ .filter(Boolean);
191
+ if (parts.length) return parts.join(' ');
192
+ }
193
+
194
+ if (el.tagName === 'IMG') {
195
+ const alt = el.getAttribute('alt');
196
+ if (alt && alt.trim()) return alt.trim();
197
+ }
198
+
199
+ const title = el.getAttribute('title');
200
+ if (title && title.trim()) return title.trim();
201
+
202
+ if (el.labels && el.labels[0]) {
203
+ const t = (el.labels[0].textContent || '').trim();
204
+ if (t) return t;
205
+ }
206
+
207
+ const text = (el.textContent || '').trim();
208
+ return text ? truncate(text, 40) : '';
209
+ }
210
+
211
+ /* ── DOM state ────────────────────────────────── */
212
+
213
+ /**
214
+ * Capture real runtime state (not just ARIA attributes). This is what
215
+ * separates a useful map from a static screenshot annotation: an agent can
216
+ * see "checkbox is unchecked / select has value=X / details is closed".
217
+ */
218
+ function computeState(el) {
219
+ const state = {};
220
+ try {
221
+ if (el.matches(':checked')) state.checked = true;
222
+ if (el.matches(':disabled')) state.disabled = true;
223
+ if (el.matches(':focus')) state.focus = true;
224
+ } catch { /* some selectors may fail on exotic nodes */ }
225
+
226
+ const tag = el.tagName;
227
+ if (tag === 'INPUT') {
228
+ // Checkbox/radio: `value` defaults to "on" when no explicit value attr
229
+ // is set — noise. The meaningful signal is `checked`. Skip value here.
230
+ const type = (el.type || 'text').toLowerCase();
231
+ if (type !== 'checkbox' && type !== 'radio' && el.value) {
232
+ state.value = el.value;
233
+ }
234
+ } else if (tag === 'TEXTAREA') {
235
+ if (el.value) state.value = el.value;
236
+ } else if (tag === 'SELECT') {
237
+ state.value = el.value;
238
+ const opt = el.options && el.options[el.selectedIndex];
239
+ if (opt) state.selectedText = opt.text || '';
240
+ } else if (tag === 'DETAILS') {
241
+ state.open = !!el.open;
242
+ } else if (el.hasAttribute && el.hasAttribute('open')) {
243
+ state.open = true;
244
+ }
245
+
246
+ return Object.keys(state).length ? state : null;
247
+ }
248
+
249
+ /* ── Visual styles ────────────────────────────── */
250
+
251
+ /**
252
+ * Pull a small set of visually-meaningful computed styles. Reads from the
253
+ * live element (the clone uses class-based styling so its inline style is
254
+ * empty and a detached `getComputedStyle(cloneNode)` returns initial values).
255
+ * The computed style of the original is what snapdom used to build the
256
+ * capture, so the result matches what the screenshot shows.
257
+ */
258
+ function computeVisualStyles(el) {
259
+ let cs;
260
+ try { cs = getComputedStyle(el); } catch { return null; }
261
+ if (!cs) return null;
262
+ const out = {};
263
+ for (const prop of VISUAL_FIELDS) {
264
+ const v = cs[prop];
265
+ if (isDefaultStyleValue(prop, v)) continue;
266
+ out[prop] = v;
267
+ }
268
+ return Object.keys(out).length ? out : null;
269
+ }
270
+
271
+ /* ── Covered detection ────────────────────────── */
272
+
273
+ /**
274
+ * True when another element is painted on top of the center of this one.
275
+ * An agent that knows a button is covered by a modal won't try to click it.
276
+ */
277
+ function isCovered(el, rect) {
278
+ if (!rect.width || !rect.height) return false;
279
+ const cx = rect.left + rect.width / 2;
280
+ const cy = rect.top + rect.height / 2;
281
+ if (cx < 0 || cy < 0) return false;
282
+ const doc = el.ownerDocument || document;
283
+ if (!doc.elementFromPoint) return false;
284
+ const top = doc.elementFromPoint(cx, cy);
285
+ if (!top || top === el) return false;
286
+ if (el.contains && el.contains(top)) return false;
287
+ return true;
288
+ }
289
+
126
290
  /* ── Metadata extraction ──────────────────────── */
127
291
 
128
292
  function extractMetadata(element, interactiveSelector, semanticSelector) {
@@ -156,11 +320,6 @@ function extractMetadata(element, interactiveSelector, semanticSelector) {
156
320
  };
157
321
  }
158
322
 
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
323
  function buildEntry(el, rootRect, id, type) {
165
324
  const rect = el.getBoundingClientRect();
166
325
  const bbox = {
@@ -173,15 +332,58 @@ function buildEntry(el, rootRect, id, type) {
173
332
  if (bbox.width <= 0 && bbox.height <= 0) return null;
174
333
 
175
334
  const tag = el.tagName.toLowerCase();
176
- const text = (el.textContent || '').trim().slice(0, 200);
335
+ const maxText = type === 'interactive' ? 200 : 120;
336
+ const text = (el.textContent || '').trim().slice(0, maxText);
177
337
 
178
338
  const attributes = {};
179
339
  for (const attr of COLLECTED_ATTRS) {
180
340
  const val = el.getAttribute(attr);
181
- if (val != null) attributes[attr] = val;
341
+ if (val == null || val === '' || val === 'false') continue;
342
+ attributes[attr] = val;
182
343
  }
183
344
 
184
- return { id, tag, type, text, bbox, attributes };
345
+ // For <img>, replace the (potentially long, cache-busted) src with just the
346
+ // filename — "logo.svg" is more useful to an LLM than the full URL.
347
+ if (tag === 'img' && el.src) {
348
+ try {
349
+ const base = (el.ownerDocument && el.ownerDocument.location)
350
+ ? el.ownerDocument.location.href
351
+ : (typeof location !== 'undefined' ? location.href : undefined);
352
+ const url = base ? new URL(el.src, base) : new URL(el.src);
353
+ attributes.src = url.pathname.split('/').pop() || el.src;
354
+ } catch {
355
+ attributes.src = el.src;
356
+ }
357
+ }
358
+
359
+ const entry = {
360
+ id,
361
+ tag,
362
+ type,
363
+ name: accessibleName(el),
364
+ text,
365
+ bbox,
366
+ attributes,
367
+ };
368
+
369
+ const styles = computeVisualStyles(el);
370
+ if (styles) entry.styles = styles;
371
+
372
+ if (type === 'interactive') {
373
+ const state = computeState(el);
374
+ if (state) {
375
+ // Drop state.value when it just echoes attributes.value — no
376
+ // divergence between the initial HTML attribute and the current
377
+ // property, nothing for the LLM to learn from the repeat.
378
+ if (state.value !== undefined && state.value === attributes.value) {
379
+ delete state.value;
380
+ }
381
+ if (Object.keys(state).length) entry.state = state;
382
+ }
383
+ if (isCovered(el, rect)) entry.covered = true;
384
+ }
385
+
386
+ return entry;
185
387
  }
186
388
 
187
389
  /* ── Visual annotations ───────────────────────── */
@@ -207,10 +409,14 @@ function addAnnotations(clone, elements, customStyle) {
207
409
  const badge = document.createElement('span');
208
410
  badge.textContent = String(el.id);
209
411
  badge.setAttribute('data-snap-prompt-label', String(el.id));
412
+ // Center the badge on the element's bbox, not on its top-left corner:
413
+ // `translate(-50%, -50%)` offsets from the anchor point.
414
+ const cx = el.bbox.x + el.bbox.width / 2;
415
+ const cy = el.bbox.y + el.bbox.height / 2;
210
416
  Object.assign(badge.style, {
211
417
  position: 'absolute',
212
- left: `${el.bbox.x}px`,
213
- top: `${el.bbox.y}px`,
418
+ left: `${cx}px`,
419
+ top: `${cy}px`,
214
420
  transform: 'translate(-50%, -50%)',
215
421
  minWidth: '18px',
216
422
  height: '18px',
@@ -234,11 +440,56 @@ function addAnnotations(clone, elements, customStyle) {
234
440
  clone.appendChild(overlay);
235
441
  }
236
442
 
237
- /* ── Prompt text formatter ────────────────────── */
443
+ /* ── Prompt text formatters ───────────────────── */
444
+
445
+ function stateToStr(state) {
446
+ if (!state) return '';
447
+ const flags = [];
448
+ const pairs = [];
449
+ for (const k of Object.keys(state)) {
450
+ const v = state[k];
451
+ if (v === true) flags.push(k);
452
+ else pairs.push(`${k}=${JSON.stringify(v)}`);
453
+ }
454
+ if (!flags.length && !pairs.length) return '';
455
+ return ` {${[...flags, ...pairs].join(', ')}}`;
456
+ }
457
+
458
+ function coordsToStr(bbox) {
459
+ return ` (${bbox.x},${bbox.y} ${bbox.width}×${bbox.height})`;
460
+ }
461
+
462
+ function formatPromptCompact(elements, dimensions, withCoords) {
463
+ const lines = [`Screenshot (${dimensions.width}×${dimensions.height}px).`];
464
+
465
+ const interactive = elements.filter((e) => e.type === 'interactive');
466
+ const semantic = elements.filter((e) => e.type === 'semantic');
238
467
 
239
- function formatPromptText(elements, dimensions) {
468
+ if (interactive.length) {
469
+ lines.push('', 'Interactive:');
470
+ for (const el of interactive) {
471
+ const name = el.name ? ` "${truncate(el.name, 60)}"` : '';
472
+ const st = stateToStr(el.state);
473
+ const cov = el.covered ? ' (covered)' : '';
474
+ const coords = withCoords ? coordsToStr(el.bbox) : '';
475
+ lines.push(` [${el.id}] ${el.tag}${name}${st}${cov}${coords}`);
476
+ }
477
+ }
478
+
479
+ if (semantic.length) {
480
+ lines.push('', 'Semantic:');
481
+ for (const el of semantic) {
482
+ const name = el.name ? ` "${truncate(el.name, 60)}"` : '';
483
+ lines.push(` [${el.id}] ${el.tag}${name}`);
484
+ }
485
+ }
486
+
487
+ return lines.join('\n');
488
+ }
489
+
490
+ function formatPromptVerbose(elements, dimensions, withCoords) {
240
491
  const lines = [
241
- `Screenshot of a web page (${dimensions.width}\u00d7${dimensions.height}px).`,
492
+ `Screenshot of a web page (${dimensions.width}×${dimensions.height}px).`,
242
493
  '',
243
494
  ];
244
495
 
@@ -248,13 +499,13 @@ function formatPromptText(elements, dimensions) {
248
499
  if (interactive.length) {
249
500
  lines.push('Interactive elements:');
250
501
  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})`;
502
+ const name = el.name ? ` "${truncate(el.name, 80)}"` : '';
503
+ const attrParts = Object.entries(el.attributes).map(([k, v]) => `${k}="${v}"`);
256
504
  const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
257
- lines.push(` [${el.id}] <${el.tag}>${text} ${pos}${attrs}`);
505
+ const pos = withCoords ? coordsToStr(el.bbox) : '';
506
+ const st = stateToStr(el.state);
507
+ const cov = el.covered ? ' (covered)' : '';
508
+ lines.push(` [${el.id}] <${el.tag}>${name}${pos}${attrs}${st}${cov}`);
258
509
  }
259
510
  lines.push('');
260
511
  }
@@ -262,20 +513,15 @@ function formatPromptText(elements, dimensions) {
262
513
  if (semantic.length) {
263
514
  lines.push('Semantic structure:');
264
515
  for (const el of semantic) {
265
- const text = el.text ? ` "${truncate(el.text, 80)}"` : '';
516
+ const name = el.name ? ` "${truncate(el.name, 80)}"` : '';
266
517
  const attrParts = [];
267
518
  if (el.attributes.alt) attrParts.push(`alt="${el.attributes.alt}"`);
268
519
  if (el.attributes.role) attrParts.push(`role="${el.attributes.role}"`);
269
520
  const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
270
- lines.push(` [${el.id}] <${el.tag}>${text}${attrs}`);
521
+ lines.push(` [${el.id}] <${el.tag}>${name}${attrs}`);
271
522
  }
272
523
  lines.push('');
273
524
  }
274
525
 
275
526
  return lines.join('\n');
276
527
  }
277
-
278
- function truncate(str, max) {
279
- if (str.length <= max) return str;
280
- return str.slice(0, max - 1) + '\u2026';
281
- }