Skip to content

Commit b451415

Browse files
tinchox5claude
andcommitted
docs(site): add prompt-export to the plugins page + live demo
- New card in the Official Plugins grid (category: export). - pluginData entry feeds the details modal (description, options, usage). - Playground tile with annotate + maxImageWidth controls. - Inline promptExportPlugin factory mirroring the shipped plugin so the demo works without an external import. - Custom output renderer: annotated screenshot + formatted prompt text + expandable elements JSON — the three things toPrompt() returns. - Enriched pgTarget with buttons, a link, and an input so annotation actually has interactive elements to latch onto (benefits the ascii and filter demos too). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f9743db commit b451415

1 file changed

Lines changed: 251 additions & 5 deletions

File tree

docs/plugins.html

Lines changed: 251 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,13 @@ <h3 style="color:#fff; margin-bottom:8px;">Hello SnapDOM</h3>
282282
<span>🚀</span><span></span><span>🧠</span><span>🖼️</span><span>🎛️</span><span>🧪</span>
283283
<span>💡</span><span>🧩</span><span>📦</span><span>🧭</span><span>⚙️</span><span>🛡️</span>
284284
</div>
285-
<span class="pg-tag" style="margin-top:10px; background:rgba(255,255,255,0.1); color:#00e0a0;">Live content</span>
285+
<div style="display:flex; gap:6px; margin-top:10px; align-items:center; flex-wrap:wrap;">
286+
<button type="button" aria-label="Primary action" style="padding:4px 10px; border-radius:6px; border:0; background:#00e0a0; color:#0d0f12; font-size:12px; cursor:pointer;">Capture</button>
287+
<button type="button" aria-label="Download capture" style="padding:4px 10px; border-radius:6px; border:1px solid rgba(255,255,255,0.2); background:transparent; color:#fff; font-size:12px; cursor:pointer;">Download</button>
288+
<a href="#" style="color:#00e0a0; font-size:12px; text-decoration:none;">docs</a>
289+
<input type="text" placeholder="filename" aria-label="Output filename" style="padding:3px 8px; border-radius:6px; border:1px solid rgba(255,255,255,0.15); background:rgba(0,0,0,0.3); color:#fff; font-size:12px; width:110px;">
290+
<span class="pg-tag" style="background:rgba(255,255,255,0.1); color:#00e0a0;">Live content</span>
291+
</div>
286292
</div>
287293
</div>
288294
<div class="pg-panel">
@@ -379,6 +385,25 @@ <h4>Plugins (combine freely)</h4>
379385
</div>
380386
</div>
381387
</div>
388+
<div class="pg-plugin" data-pg="prompt">
389+
<div class="pg-plugin-header">
390+
<input type="checkbox" id="pgChkPrompt">
391+
<span>prompt-export</span>
392+
</div>
393+
<div class="pg-plugin-opts">
394+
<div class="pg-opt">
395+
<label for="pgPromptAnnotate">Annotate</label>
396+
<select id="pgPromptAnnotate">
397+
<option value="true">on</option>
398+
<option value="false">off</option>
399+
</select>
400+
</div>
401+
<div class="pg-opt">
402+
<label for="pgPromptMaxW">Max width</label>
403+
<input type="text" id="pgPromptMaxW" value="1024" placeholder="px">
404+
</div>
405+
</div>
406+
</div>
382407
</div>
383408
</div>
384409
<button class="pg-capture-btn" id="pgCapture" data-umami-event="Plugins playground capture">Capture</button>
@@ -531,6 +556,15 @@ <h3>html-in-canvas</h3>
531556
<div class="card-footer"><span class="official-badge">official</span><a href="https://github.com/zumerlab/snapdom/blob/main/packages/plugins/html-in-canvas.js" data-umami-event="Plugins source html-in-canvas">Source</a><span class="expand-hint">details</span></div>
532557
</div>
533558

559+
<!-- prompt-export -->
560+
<div class="plugin-card" data-category="export" data-plugin="prompt-export">
561+
<div class="card-top"><div class="plugin-icon">L</div><span class="category-tag">export</span></div>
562+
<h3>prompt-export</h3>
563+
<p>Adds a toPrompt() method that returns an LLM-ready package: annotated screenshot, element map with bounding boxes, and a formatted prompt text.</p>
564+
<div class="hooks"><span class="hook-badge">afterClone</span><span class="hook-badge">defineExports</span></div>
565+
<div class="card-footer"><span class="official-badge">official</span><a href="https://github.com/zumerlab/snapdom/blob/main/packages/plugins/prompt-export.js" data-umami-event="Plugins source prompt-export">Source</a><span class="expand-hint">details</span></div>
566+
</div>
567+
534568
</div>
535569
</section>
536570

@@ -700,6 +734,21 @@ <h2>Build a Plugin in 5 Minutes</h2>
700734
note: 'Requires Chrome with chrome://flags/#canvas-draw-element enabled.',
701735
usage: `import { htmlInCanvasPlugin } from '@zumer/snapdom-plugins/html-in-canvas';\n\nconst result = await snapdom(el, {\n plugins: [htmlInCanvasPlugin()]\n});\nconst canvas = await result.htmlInCanvas({ scale: 2 });`
702736
},
737+
'prompt-export': {
738+
icon: 'L', category: 'export',
739+
desc: 'Adds a toPrompt() method that returns an LLM-ready package: annotated screenshot, structured element map with bounding boxes, and pre-formatted prompt text.',
740+
hooks: ['afterClone', 'defineExports'],
741+
options: [
742+
['annotate', 'boolean', 'true', 'Overlay numbered badges on interactive elements'],
743+
['imageFormat', 'string', "'png'", "'png' | 'jpg' | 'webp'"],
744+
['imageQuality', 'number', '0.8', 'Quality for lossy formats (0-1)'],
745+
['maxImageWidth', 'number', '1024', 'Max output width in px; downscales and rescales bboxes'],
746+
['interactiveSelector', 'string', 'see docs', 'CSS selector for the interactive element set'],
747+
['semanticSelector', 'string', 'see docs', 'CSS selector for the semantic element set'],
748+
['labelStyle', 'object', '{}', 'Override styles for the numbered badges'],
749+
],
750+
usage: `import { promptExport } from '@zumer/snapdom-plugins/prompt-export';\n\nconst result = await snapdom(el, {\n plugins: [promptExport({ annotate: true, maxImageWidth: 1024 })]\n});\nconst { image, elements, dimensions, prompt } = await result.toPrompt();`
751+
},
703752
};
704753

705754
const categoryColors = {
@@ -1090,6 +1139,162 @@ <h3>${name}</h3>
10901139
};
10911140
}
10921141

1142+
// prompt-export — LLM-ready package: annotated image + element map + prompt text
1143+
function promptExportPlugin(options = {}) {
1144+
const {
1145+
annotate = true,
1146+
imageFormat = 'png',
1147+
imageQuality = 0.8,
1148+
maxImageWidth = 1024,
1149+
labelStyle = {},
1150+
} = options;
1151+
const INTERACTIVE = 'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], [tabindex]:not([tabindex="-1"]), summary, [contenteditable="true"]';
1152+
const SEMANTIC = 'h1, h2, h3, h4, h5, h6, p, li, img[alt], nav, main, article, section, header, footer, label, td, th, figcaption, blockquote, legend';
1153+
const COLLECTED_ATTRS = ['role', 'aria-label', 'aria-expanded', 'aria-checked', 'aria-disabled', 'alt', 'href', 'placeholder', 'name', 'type', 'value', 'title', 'disabled'];
1154+
1155+
function buildEntry(el, rootRect, id, type) {
1156+
const rect = el.getBoundingClientRect();
1157+
const bbox = {
1158+
x: Math.round(rect.left - rootRect.left),
1159+
y: Math.round(rect.top - rootRect.top),
1160+
width: Math.round(rect.width),
1161+
height: Math.round(rect.height),
1162+
};
1163+
if (bbox.width <= 0 && bbox.height <= 0) return null;
1164+
const tag = el.tagName.toLowerCase();
1165+
const text = (el.textContent || '').trim().slice(0, 200);
1166+
const attributes = {};
1167+
for (const attr of COLLECTED_ATTRS) {
1168+
const val = el.getAttribute(attr);
1169+
if (val != null) attributes[attr] = val;
1170+
}
1171+
return { id, tag, type, text, bbox, attributes };
1172+
}
1173+
function extractMetadata(element) {
1174+
const rootRect = element.getBoundingClientRect();
1175+
const elements = [];
1176+
let id = 0;
1177+
const tracked = new Set();
1178+
for (const el of element.querySelectorAll(INTERACTIVE)) {
1179+
const entry = buildEntry(el, rootRect, id, 'interactive');
1180+
if (entry) { elements.push(entry); tracked.add(el); id++; }
1181+
}
1182+
for (const el of element.querySelectorAll(SEMANTIC)) {
1183+
if (tracked.has(el)) continue;
1184+
const entry = buildEntry(el, rootRect, id, 'semantic');
1185+
if (entry) { elements.push(entry); id++; }
1186+
}
1187+
return { elements, dimensions: { width: rootRect.width, height: rootRect.height } };
1188+
}
1189+
function addAnnotations(clone, elements) {
1190+
const interactive = elements.filter(e => e.type === 'interactive');
1191+
if (!interactive.length) return;
1192+
const overlay = document.createElement('div');
1193+
Object.assign(overlay.style, {
1194+
position: 'absolute', top: '0', left: '0', width: '100%', height: '100%',
1195+
pointerEvents: 'none', zIndex: '2147483647', overflow: 'visible',
1196+
});
1197+
for (const el of interactive) {
1198+
const badge = document.createElement('span');
1199+
badge.textContent = String(el.id);
1200+
Object.assign(badge.style, {
1201+
position: 'absolute', left: el.bbox.x + 'px', top: el.bbox.y + 'px',
1202+
transform: 'translate(-50%, -50%)', minWidth: '18px', height: '18px',
1203+
lineHeight: '18px', fontSize: '11px', fontWeight: '700',
1204+
fontFamily: 'system-ui, -apple-system, sans-serif',
1205+
color: '#fff', backgroundColor: 'rgba(220, 38, 38, 0.92)',
1206+
borderRadius: '9px', textAlign: 'center', padding: '0 4px',
1207+
boxSizing: 'border-box', boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
1208+
...labelStyle,
1209+
});
1210+
overlay.appendChild(badge);
1211+
}
1212+
clone.style.position = 'relative';
1213+
clone.appendChild(overlay);
1214+
}
1215+
function formatPromptText(elements, dim) {
1216+
const lines = [`Screenshot of a web page (${dim.width}×${dim.height}px).`, ''];
1217+
const interactive = elements.filter(e => e.type === 'interactive');
1218+
const semantic = elements.filter(e => e.type === 'semantic');
1219+
if (interactive.length) {
1220+
lines.push('Interactive elements:');
1221+
for (const el of interactive) {
1222+
const attrParts = Object.entries(el.attributes).map(([k, v]) => `${k}="${v}"`);
1223+
const text = el.text ? ` "${el.text.length > 60 ? el.text.slice(0, 59) + '…' : el.text}"` : '';
1224+
const pos = `(${el.bbox.x},${el.bbox.y} ${el.bbox.width}×${el.bbox.height})`;
1225+
const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
1226+
lines.push(` [${el.id}] <${el.tag}>${text} ${pos}${attrs}`);
1227+
}
1228+
lines.push('');
1229+
}
1230+
if (semantic.length) {
1231+
lines.push('Semantic structure:');
1232+
for (const el of semantic) {
1233+
const text = el.text ? ` "${el.text.length > 80 ? el.text.slice(0, 79) + '…' : el.text}"` : '';
1234+
const attrParts = [];
1235+
if (el.attributes.alt) attrParts.push(`alt="${el.attributes.alt}"`);
1236+
if (el.attributes.role) attrParts.push(`role="${el.attributes.role}"`);
1237+
const attrs = attrParts.length ? ' ' + attrParts.join(' ') : '';
1238+
lines.push(` [${el.id}] <${el.tag}>${text}${attrs}`);
1239+
}
1240+
}
1241+
return lines.join('\n');
1242+
}
1243+
1244+
return {
1245+
name: 'prompt-export',
1246+
afterClone(ctx) {
1247+
const meta = extractMetadata(ctx.element);
1248+
// snapdom spreads a fresh ctx for the export phase from ctx.options,
1249+
// so stash on both so the prompt() call below can read it.
1250+
ctx.__promptMetadata = meta;
1251+
if (ctx.options) ctx.options.__promptMetadata = meta;
1252+
if (annotate) addAnnotations(ctx.clone, meta.elements);
1253+
},
1254+
defineExports() {
1255+
return {
1256+
prompt: async (ctx, opts = {}) => {
1257+
const meta = ctx.__promptMetadata;
1258+
if (!meta || !meta.elements.length) {
1259+
return { image: ctx.export.url, elements: [], dimensions: { width: 0, height: 0 }, prompt: '' };
1260+
}
1261+
const fmt = opts.imageFormat || imageFormat;
1262+
const q = opts.imageQuality || imageQuality;
1263+
const maxW = opts.maxImageWidth || maxImageWidth;
1264+
const img = new Image();
1265+
img.src = ctx.export.url;
1266+
await new Promise((res, rej) => { img.onload = res; img.onerror = rej; });
1267+
const ratio = img.naturalWidth > maxW ? maxW / img.naturalWidth : 1;
1268+
const w = Math.round(img.naturalWidth * ratio);
1269+
const h = Math.round(img.naturalHeight * ratio);
1270+
const canvas = document.createElement('canvas');
1271+
canvas.width = w; canvas.height = h;
1272+
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
1273+
const mime = fmt === 'jpg' || fmt === 'jpeg' ? 'image/jpeg' : fmt === 'webp' ? 'image/webp' : 'image/png';
1274+
const dataURL = canvas.toDataURL(mime, q);
1275+
const sx = w / (meta.dimensions.width || 1);
1276+
const sy = h / (meta.dimensions.height || 1);
1277+
const elements = meta.elements.map(el => ({
1278+
...el,
1279+
bbox: {
1280+
x: Math.round(el.bbox.x * sx),
1281+
y: Math.round(el.bbox.y * sy),
1282+
width: Math.round(el.bbox.width * sx),
1283+
height: Math.round(el.bbox.height * sy),
1284+
},
1285+
}));
1286+
return {
1287+
image: dataURL,
1288+
elements,
1289+
dimensions: { width: w, height: h },
1290+
prompt: formatPromptText(elements, { width: w, height: h }),
1291+
};
1292+
},
1293+
};
1294+
},
1295+
};
1296+
}
1297+
10931298
// ── DOM refs ──
10941299
const $ = id => document.getElementById(id);
10951300
const pgTarget = $('pgTarget');
@@ -1131,6 +1336,8 @@ <h3>${name}</h3>
11311336
const calls = [];
11321337
let hasAscii = false;
11331338
let hasPdf = false;
1339+
let hasPrompt = false;
1340+
let promptOpts = null;
11341341

11351342
if ($('pgChkFilter').checked) {
11361343
const preset = $('pgFilterPreset').value;
@@ -1173,11 +1380,23 @@ <h3>${name}</h3>
11731380
calls.push(orient !== 'portrait' ? `pdfImage({ orientation: '${orient}' })` : `pdfImage()`);
11741381
hasPdf = true;
11751382
}
1176-
return { plugins, imports, calls, hasAscii, hasPdf };
1383+
if ($('pgChkPrompt').checked) {
1384+
const annotate = $('pgPromptAnnotate').value === 'true';
1385+
const maxW = parseInt($('pgPromptMaxW').value, 10) || 1024;
1386+
promptOpts = { annotate, maxImageWidth: maxW };
1387+
plugins.push(promptExportPlugin(promptOpts));
1388+
imports.push(`import { promptExport } from '@zumer/snapdom-plugins/prompt-export';`);
1389+
const parts = [];
1390+
if (!annotate) parts.push('annotate: false');
1391+
if (maxW !== 1024) parts.push(`maxImageWidth: ${maxW}`);
1392+
calls.push(parts.length ? `promptExport({ ${parts.join(', ')} })` : `promptExport()`);
1393+
hasPrompt = true;
1394+
}
1395+
return { plugins, imports, calls, hasAscii, hasPdf, hasPrompt, promptOpts };
11771396
}
11781397

11791398
function updateCode() {
1180-
const { imports, calls, hasAscii, hasPdf } = getActivePlugins();
1399+
const { imports, calls, hasAscii, hasPdf, hasPrompt } = getActivePlugins();
11811400
if (!calls.length) {
11821401
pgCode.textContent = '// Select plugins to see the code';
11831402
return;
@@ -1193,6 +1412,7 @@ <h3>${name}</h3>
11931412
`const png = await result.toPng();`;
11941413
if (hasAscii) code += `\nconst ascii = await result.toAscii();`;
11951414
if (hasPdf) code += `\nawait result.toPdfImage(); // downloads PDF`;
1415+
if (hasPrompt) code += `\nconst { image, elements, prompt } = await result.toPrompt();`;
11961416
pgCode.textContent = code;
11971417
}
11981418

@@ -1202,11 +1422,37 @@ <h3>${name}</h3>
12021422
pgStatus.textContent = 'Capturing...';
12031423
try {
12041424
const t0 = performance.now();
1205-
const { plugins, calls, hasAscii, hasPdf } = getActivePlugins();
1425+
const { plugins, calls, hasAscii, hasPdf, hasPrompt } = getActivePlugins();
12061426
const result = await snapdom(pgTarget, { plugins });
12071427
pgOutput.innerHTML = '';
12081428

1209-
if (hasAscii) {
1429+
if (hasPrompt) {
1430+
// prompt-export mode — show annotated image + formatted prompt + elements JSON
1431+
const { image, elements, prompt } = await result.toPrompt();
1432+
const wrap = document.createElement('div');
1433+
wrap.style.cssText = 'display:flex;flex-direction:column;gap:12px;width:100%;';
1434+
const img = document.createElement('img');
1435+
img.src = image;
1436+
img.alt = 'Annotated capture';
1437+
img.style.cssText = 'max-width:100%;border-radius:6px;display:block;';
1438+
wrap.appendChild(img);
1439+
const promptBox = document.createElement('pre');
1440+
promptBox.textContent = prompt;
1441+
promptBox.style.cssText = 'margin:0;padding:12px;background:rgba(0,0,0,0.35);border:1px solid rgba(255,255,255,0.08);border-radius:6px;font-size:11px;line-height:1.45;font-family:ui-monospace,Menlo,monospace;color:#cfd8dc;overflow:auto;max-height:260px;white-space:pre-wrap;';
1442+
wrap.appendChild(promptBox);
1443+
const elementsBox = document.createElement('details');
1444+
elementsBox.style.cssText = 'font-size:11px;color:#8b97a5;';
1445+
const summary = document.createElement('summary');
1446+
summary.textContent = `elements JSON — ${elements.length} items`;
1447+
summary.style.cssText = 'cursor:pointer;padding:4px 0;';
1448+
elementsBox.appendChild(summary);
1449+
const elementsPre = document.createElement('pre');
1450+
elementsPre.textContent = JSON.stringify(elements, null, 2);
1451+
elementsPre.style.cssText = 'margin:8px 0 0 0;padding:12px;background:rgba(0,0,0,0.35);border:1px solid rgba(255,255,255,0.08);border-radius:6px;font-size:11px;line-height:1.45;font-family:ui-monospace,Menlo,monospace;color:#cfd8dc;overflow:auto;max-height:200px;';
1452+
elementsBox.appendChild(elementsPre);
1453+
wrap.appendChild(elementsBox);
1454+
pgOutput.appendChild(wrap);
1455+
} else if (hasAscii) {
12101456
// ASCII mode — call toAscii directly (same pattern as docs/index.html)
12111457
const cols = parseInt($('pgAsciiW').value) || 100;
12121458
const asciiHTML = await result.toAscii({ cols, invert: true });

0 commit comments

Comments
 (0)