@@ -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