From 81f880d0304d2c1fe7f0c470daa117459ae67394 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 21 Apr 2026 09:38:48 -0700 Subject: [PATCH] feat(live): annotation capture, comment pins, drawing, and halftone loading shader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a full annotation pipeline to /impeccable live. On Go, the browser captures the selected element as a PNG (with annotations composed in), uploads it to the live helper, and sends the generate event with the screenshot path so the agent reads user intent visually instead of from HTML alone. Annotation tools (while an element is picked): - Click inside the outline to drop a magenta comment pin with a text input - Drag to paint a magenta SVG stroke (5 px click-vs-drag threshold) - Click a pin to edit; double-click to delete; drag a pin to reposition - Click a stroke to delete it (wider invisible hit path) - Clear chip top-right wipes everything; hidden when no annotations Capture pipeline: - modern-screenshot vendored as an IIFE (scripts/modern-screenshot.umd.js) and lazy-loaded from the live helper - Font fix: cross-origin @font-face rules are fetched and fonts are inlined as base64 data URIs before being handed to modern-screenshot via font.cssText, since SVGs rasterized via canvas can't fetch external resources (fix for "Impeccable" rendering bold-serif and items wrapping wrong in the capture) - Annotations are temporarily attached to the live element (not only the clone) so computed styles resolve during the embed pass - Session screenshots live in .impeccable-live/annotations/session-*/ in the project root (gitignored) so the agent's Read tool doesn't trip a per-path permission prompt Loading shader (activates during GENERATING): - WebGL overlay rendering the captured PNG as a halftone — cells with luma-driven dot radius, rendered on paper-cream underneath a magenta roller that sweeps top-to-bottom with a 3.4s cycle and clean overshoot - Fixed asymmetric bandAt() using one-sided smoothsteps (previous reversed smoothstep was undefined on d>0, giving "trail=1 everywhere below") - Graceful fallback when WebGL is unavailable; prefers-reduced-motion freezes the band at t=0 Server: - POST /annotation endpoint (raw image/png body, token + eventId query), session-scoped tmpdir cleaned up on shutdown - GET /modern-screenshot.js serves the vendored UMD with aggressive caching - Optional screenshotPath / comments / strokes fields on generate events - Fixed pre-existing /source crash on ENOENT (writeHead called twice) Agent side: - reference/live.md step 0 tells the agent to Read the screenshot first, with four rules for interpreting annotations: comments are position- anchored and scoped to the sub-element under their {x,y}; strokes are gestures (loop=focus, arrow=direction, cross=delete); comments and strokes are independent unless adjacent; don't silently guess on ambiguous strokes Also: - Generating bar no longer claims "Generating 1 of 3..." (variants arrive atomically) — now says "Generating N variants..." - tests/live-server.test.mjs fixed to read the PID file from project root, matching the server; adds coverage for the new endpoints and validator fields - .impeccable-live/ added to .gitignore Co-Authored-By: Claude Opus 4.7 (1M context) --- .agents/skills/impeccable/reference/live.md | 15 +- .../skills/impeccable/scripts/live-browser.js | 926 +++++++++++++++++- .../skills/impeccable/scripts/live-server.mjs | 119 ++- .../scripts/modern-screenshot.umd.js | 14 + .claude/skills/impeccable/reference/live.md | 15 +- .../skills/impeccable/scripts/live-browser.js | 926 +++++++++++++++++- .../skills/impeccable/scripts/live-server.mjs | 119 ++- .../scripts/modern-screenshot.umd.js | 14 + .cursor/skills/impeccable/reference/live.md | 15 +- .../skills/impeccable/scripts/live-browser.js | 926 +++++++++++++++++- .../skills/impeccable/scripts/live-server.mjs | 119 ++- .../scripts/modern-screenshot.umd.js | 14 + .gemini/skills/impeccable/reference/live.md | 15 +- .../skills/impeccable/scripts/live-browser.js | 926 +++++++++++++++++- .../skills/impeccable/scripts/live-server.mjs | 119 ++- .../scripts/modern-screenshot.umd.js | 14 + .github/skills/impeccable/reference/live.md | 15 +- .../skills/impeccable/scripts/live-browser.js | 926 +++++++++++++++++- .../skills/impeccable/scripts/live-server.mjs | 119 ++- .../scripts/modern-screenshot.umd.js | 14 + .gitignore | 3 +- .kiro/skills/impeccable/reference/live.md | 15 +- .../skills/impeccable/scripts/live-browser.js | 926 +++++++++++++++++- .../skills/impeccable/scripts/live-server.mjs | 119 ++- .../scripts/modern-screenshot.umd.js | 14 + .opencode/skills/impeccable/reference/live.md | 15 +- .../skills/impeccable/scripts/live-browser.js | 926 +++++++++++++++++- .../skills/impeccable/scripts/live-server.mjs | 119 ++- .../scripts/modern-screenshot.umd.js | 14 + .pi/skills/impeccable/reference/live.md | 15 +- .pi/skills/impeccable/scripts/live-browser.js | 926 +++++++++++++++++- .pi/skills/impeccable/scripts/live-server.mjs | 119 ++- .../scripts/modern-screenshot.umd.js | 14 + .rovodev/skills/impeccable/reference/live.md | 15 +- .../skills/impeccable/scripts/live-browser.js | 926 +++++++++++++++++- .../skills/impeccable/scripts/live-server.mjs | 119 ++- .../scripts/modern-screenshot.umd.js | 14 + .trae-cn/skills/impeccable/reference/live.md | 15 +- .../skills/impeccable/scripts/live-browser.js | 926 +++++++++++++++++- .../skills/impeccable/scripts/live-server.mjs | 119 ++- .../scripts/modern-screenshot.umd.js | 14 + .trae/skills/impeccable/reference/live.md | 15 +- .../skills/impeccable/scripts/live-browser.js | 926 +++++++++++++++++- .../skills/impeccable/scripts/live-server.mjs | 119 ++- .../scripts/modern-screenshot.umd.js | 14 + bun.lock | 3 + package.json | 1 + source/skills/impeccable/reference/live.md | 15 +- .../skills/impeccable/scripts/live-browser.js | 879 ++++++++++++++++- .../skills/impeccable/scripts/live-server.mjs | 119 ++- .../scripts/modern-screenshot.umd.js | 14 + tests/live-server.test.mjs | 96 +- 52 files changed, 12583 insertions(+), 361 deletions(-) create mode 100644 .agents/skills/impeccable/scripts/modern-screenshot.umd.js create mode 100644 .claude/skills/impeccable/scripts/modern-screenshot.umd.js create mode 100644 .cursor/skills/impeccable/scripts/modern-screenshot.umd.js create mode 100644 .gemini/skills/impeccable/scripts/modern-screenshot.umd.js create mode 100644 .github/skills/impeccable/scripts/modern-screenshot.umd.js create mode 100644 .kiro/skills/impeccable/scripts/modern-screenshot.umd.js create mode 100644 .opencode/skills/impeccable/scripts/modern-screenshot.umd.js create mode 100644 .pi/skills/impeccable/scripts/modern-screenshot.umd.js create mode 100644 .rovodev/skills/impeccable/scripts/modern-screenshot.umd.js create mode 100644 .trae-cn/skills/impeccable/scripts/modern-screenshot.umd.js create mode 100644 .trae/skills/impeccable/scripts/modern-screenshot.umd.js create mode 100644 source/skills/impeccable/scripts/modern-screenshot.umd.js diff --git a/.agents/skills/impeccable/reference/live.md b/.agents/skills/impeccable/reference/live.md index 5323c03bc..90f3317ea 100644 --- a/.agents/skills/impeccable/reference/live.md +++ b/.agents/skills/impeccable/reference/live.md @@ -107,10 +107,23 @@ END LOOP ## Handle Generate -The event contains: `{id, action, freeformPrompt, count, pageUrl, element}`. +The event contains: `{id, action, freeformPrompt, count, pageUrl, element, screenshotPath?, comments?, strokes?}`. **Speed matters.** The user is watching a spinner. Minimize tool calls by using the `wrap` helper and writing all variants in a single edit. +### Step 0: If `screenshotPath` is present, Read it + +When the browser successfully captured the selected element, `event.screenshotPath` is an absolute path to a PNG showing the element as the user actually sees it — including any comment pins or drawn strokes the user placed before hitting Go. **Read it before planning variants.** The annotations encode user intent that is not recoverable from `element.outerHTML` alone (a circle around a piece of whitespace, an arrow pointing to an alignment issue, a "make this bolder" note on a specific sub-element). + +If `event.comments` or `event.strokes` are set, they carry structured metadata (comment text + positions, stroke polylines) alongside the visual. Treat the screenshot as primary; use the structured data for specifics worth quoting verbatim (e.g. the exact text of a comment). + +**Reading annotations precisely:** + +- **A comment's position is load-bearing.** Its `{x, y}` (element-local CSS px, same coord space as `element.boundingRect`) tells you which sub-element it refers to. Find the child under that point and apply the comment text LOCALLY to that sub-element. A comment near the title is about the title, not a description of "the screenshot." +- **Treat comments and strokes as independent annotations** unless they are clearly paired by position (overlap or tight proximity). Do NOT let the visual weight of a prominent stroke override the precise location of a textually-specific comment elsewhere in the element. +- **Strokes are gestures — read them by shape, not as a mask.** A closed loop = "this thing" (emphasis / focus); an arrow = direction (move / point to); a cross or slash = delete; a free scribble = emphasis or delete depending on context. A loop around region X does NOT mean "only change pixels inside X"; it means "pay attention to X." +- **When a stroke's intent is ambiguous** (circle or arrow? emphasis or move?), state your reading in one sentence as part of your rationale rather than silently guessing. If the uncertainty materially changes the brief, ask the user for one quick clarification before generating. + ### Step 1: Wrap the element (one CLI call) Use the `wrap` helper to find the element and create the variant container: diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index b9332e10d..2f3b7ece9 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -47,6 +47,14 @@ const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 }; const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint const PREFIX = 'impeccable-live'; + const HIGHLIGHT_TRANSITION = + 'top 140ms ' + EASE + + ', left 140ms ' + EASE + + ', width 140ms ' + EASE + + ', height 140ms ' + EASE + + ', opacity 150ms ease'; + const TOOLTIP_TRANSITION = + 'top 140ms ' + EASE + ', left 140ms ' + EASE + ', opacity 150ms ease'; const SKIP_TAGS = new Set([ 'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr', @@ -128,9 +136,7 @@ position: 'fixed', top: '0', left: '0', width: '0', height: '0', border: '2px solid ' + C.brand, borderRadius: '3px', pointerEvents: 'none', zIndex: Z.highlight, boxSizing: 'border-box', - // No transition on position/size: avoids layout-property animation detection - // AND gives instant cursor tracking (no lag) - transition: 'opacity 0.15s ease', + transition: HIGHLIGHT_TRANSITION, display: 'none', opacity: '0', }); document.body.appendChild(highlightEl); @@ -145,6 +151,7 @@ zIndex: Z.highlight + 1, pointerEvents: 'none', whiteSpace: 'nowrap', display: 'none', letterSpacing: '0.02em', + transition: TOOLTIP_TRANSITION, }); document.body.appendChild(tooltipEl); } @@ -152,22 +159,500 @@ function showHighlight(el) { if (!el || !highlightEl) return; const r = el.getBoundingClientRect(); - Object.assign(highlightEl.style, { - top: (r.top - 2) + 'px', left: (r.left - 2) + 'px', - width: (r.width + 4) + 'px', height: (r.height + 4) + 'px', - display: 'block', opacity: '1', - }); - tooltipEl.textContent = desc(el); + const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px'; + const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px'; const tipTop = r.top - 20; - Object.assign(tooltipEl.style, { - top: (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px', - left: Math.max(4, r.left) + 'px', display: 'block', - }); + const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px'; + const tipX = Math.max(4, r.left) + 'px'; + tooltipEl.textContent = desc(el); + + const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0'; + if (hiWasHidden) { + // Snap to first target without animating from (0,0), then fade in. + highlightEl.style.transition = 'none'; + Object.assign(highlightEl.style, { top, left, width, height, display: 'block' }); + tooltipEl.style.transition = 'none'; + Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' }); + void highlightEl.offsetWidth; + highlightEl.style.transition = HIGHLIGHT_TRANSITION; + highlightEl.style.opacity = '1'; + tooltipEl.style.transition = TOOLTIP_TRANSITION; + tooltipEl.style.opacity = '1'; + } else { + Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' }); + Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' }); + } } function hideHighlight() { if (highlightEl) { highlightEl.style.opacity = '0'; highlightEl.style.display = 'none'; } - if (tooltipEl) tooltipEl.style.display = 'none'; + if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; } + } + + // --------------------------------------------------------------------------- + // Annotation overlay (comment pins + magenta strokes) + // + // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned + // sibling of mirroring selectedElement's bounding rect. Click (no + // drag) drops a comment pin; drag paints a magenta SVG stroke. All coords + // are stored in element-local CSS px so they survive scroll / resize and + // correlate directly with the captured PNG. + // --------------------------------------------------------------------------- + + const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click + const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it + let annotOverlayEl = null; + let annotSvgEl = null; + let annotPinsEl = null; + let annotClearChipEl = null; + let annotState = { comments: [], strokes: [] }; + let annotActive = false; + // `annotPointer` is either: + // { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin + // { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin + let annotPointer = null; + let annotEditing = null; // { idx, input, wrapEl } + let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete + + function initAnnotOverlay() { + annotOverlayEl = document.createElement('div'); + annotOverlayEl.id = PREFIX + '-annot'; + Object.assign(annotOverlayEl.style, { + position: 'fixed', top: '0', left: '0', width: '0', height: '0', + pointerEvents: 'auto', zIndex: Z.highlight + 2, + display: 'none', overflow: 'visible', + cursor: 'crosshair', touchAction: 'none', + }); + + annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + annotSvgEl.id = PREFIX + '-annot-svg'; + Object.assign(annotSvgEl.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', + // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via + // pointer-events=stroke so gaps still fall through to the overlay. + pointerEvents: 'none', overflow: 'visible', + }); + annotOverlayEl.appendChild(annotSvgEl); + + annotPinsEl = document.createElement('div'); + annotPinsEl.id = PREFIX + '-annot-pins'; + Object.assign(annotPinsEl.style, { + position: 'absolute', inset: '0', + pointerEvents: 'none', + }); + annotOverlayEl.appendChild(annotPinsEl); + + annotClearChipEl = document.createElement('div'); + annotClearChipEl.id = PREFIX + '-annot-clear'; + annotClearChipEl.dataset.annotClear = 'true'; + annotClearChipEl.textContent = 'Clear'; + Object.assign(annotClearChipEl.style, { + position: 'absolute', top: '8px', right: '8px', + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '10px', fontWeight: '500', + letterSpacing: '0.08em', textTransform: 'uppercase', + padding: '5px 12px', borderRadius: '999px', + cursor: 'pointer', pointerEvents: 'auto', + display: 'none', userSelect: 'none', + boxShadow: '0 1px 3px rgba(0,0,0,0.2)', + }); + annotOverlayEl.appendChild(annotClearChipEl); + + annotOverlayEl.addEventListener('pointerdown', onAnnotDown); + annotOverlayEl.addEventListener('pointermove', onAnnotMove); + annotOverlayEl.addEventListener('pointerup', onAnnotUp); + annotOverlayEl.addEventListener('pointercancel', onAnnotUp); + document.body.appendChild(annotOverlayEl); + } + + function updateClearChip() { + if (!annotClearChipEl) return; + const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0; + annotClearChipEl.style.display = hasAny ? 'block' : 'none'; + } + + function showAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + annotActive = true; + positionAnnotOverlay(el); + annotOverlayEl.style.display = 'block'; + } + + function hideAnnotOverlay() { + annotActive = false; + if (annotOverlayEl) annotOverlayEl.style.display = 'none'; + // Drop any in-progress edit without touching annotState — clearAnnotations + // (if the caller is exiting configure mode) handles state reset. + annotEditing = null; + } + + function positionAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + const r = el.getBoundingClientRect(); + Object.assign(annotOverlayEl.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height); + } + + function clearAnnotations() { + annotState.comments = []; + annotState.strokes = []; + if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + if (annotPinsEl) annotPinsEl.innerHTML = ''; + annotPointer = null; + annotEditing = null; + annotLastPinClick = { idx: -1, time: 0 }; + updateClearChip(); + } + + // Rebuild the SVG layer. Each stroke gets a wider invisible hit path + // beneath the visible magenta path so clicks register on thin lines. + function redrawStrokes() { + while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + annotState.strokes.forEach((s, idx) => { + const d = pointsToPath(s.points); + const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + hit.setAttribute('d', d); + hit.setAttribute('stroke', 'transparent'); + hit.setAttribute('stroke-width', '16'); + hit.setAttribute('stroke-linecap', 'round'); + hit.setAttribute('stroke-linejoin', 'round'); + hit.setAttribute('fill', 'none'); + hit.setAttribute('pointer-events', 'stroke'); + hit.style.cursor = 'pointer'; + hit.dataset.annotStroke = String(idx); + annotSvgEl.appendChild(hit); + const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + visible.setAttribute('d', d); + visible.setAttribute('stroke', C.brand); + visible.setAttribute('stroke-width', '3'); + visible.setAttribute('stroke-linecap', 'round'); + visible.setAttribute('stroke-linejoin', 'round'); + visible.setAttribute('fill', 'none'); + visible.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(visible); + }); + updateClearChip(); + } + + function localCoords(e) { + const rect = annotOverlayEl.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + function onAnnotDown(e) { + if (!annotActive) return; + + // 1) Clear chip → wipe all annotations + if (e.target.closest?.('[data-annot-clear]')) { + if (annotEditing) annotEditing = null; + clearAnnotations(); + renderAllPins(); + redrawStrokes(); + e.stopPropagation(); e.preventDefault(); + return; + } + + // 2) Stroke hit path → delete that stroke + const strokeHit = e.target.closest?.('[data-annot-stroke]'); + if (strokeHit) { + const idx = parseInt(strokeHit.dataset.annotStroke, 10); + if (Number.isInteger(idx)) { + annotState.strokes.splice(idx, 1); + redrawStrokes(); + } + e.stopPropagation(); e.preventDefault(); + return; + } + + // 3) Pin → drag, edit, or delete-on-double-click + const pinWrap = e.target.closest?.('[data-annot-pin]'); + if (pinWrap) { + const idx = parseInt(pinWrap.dataset.annotPin, 10); + if (!Number.isInteger(idx)) return; + // Double-click (two pointerdowns on the same pin within window) → delete. + const now = Date.now(); + if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) { + if (annotEditing && annotEditing.idx === idx) annotEditing = null; + annotState.comments.splice(idx, 1); + annotLastPinClick = { idx: -1, time: 0 }; + renderAllPins(); + e.stopPropagation(); e.preventDefault(); + return; + } + annotLastPinClick = { idx, time: now }; + // If editing a different pin, commit that edit before starting here. + if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin(); + // If already editing THIS pin and the user clicked the dot, let the + // input keep focus (don't start a drag — the click wasn't meant as one). + if (annotEditing && annotEditing.idx === idx) return; + const p = localCoords(e); + const pin = annotState.comments[idx]; + annotPointer = { + kind: 'pin', idx, + startPointer: p, + startPin: { x: pin.x, y: pin.y }, + moved: false, + }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + return; + } + + // 4) Empty area → commit any open edit, then start new annotation + if (annotEditing) { + finalizeEditingPin(); + e.stopPropagation(); e.preventDefault(); + return; + } + const p = localCoords(e); + annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + } + + function onAnnotMove(e) { + if (!annotActive || !annotPointer) return; + const p = localCoords(e); + + if (annotPointer.kind === 'pin') { + const dx = p.x - annotPointer.startPointer.x; + const dy = p.y - annotPointer.startPointer.y; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + } + const pin = annotState.comments[annotPointer.idx]; + if (!pin) { annotPointer = null; return; } + pin.x = annotPointer.startPin.x + dx; + pin.y = annotPointer.startPin.y + dy; + renderAllPins(); + e.stopPropagation(); + return; + } + + // kind === 'new' + const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + strokeEl.setAttribute('stroke', C.brand); + strokeEl.setAttribute('stroke-width', '3'); + strokeEl.setAttribute('stroke-linecap', 'round'); + strokeEl.setAttribute('stroke-linejoin', 'round'); + strokeEl.setAttribute('fill', 'none'); + strokeEl.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(strokeEl); + annotPointer.strokeEl = strokeEl; + annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]]; + } + annotPointer.strokePoints.push([p.x, p.y]); + annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints)); + e.stopPropagation(); + } + + function onAnnotUp(e) { + if (!annotActive || !annotPointer) return; + + if (annotPointer.kind === 'pin') { + const wasDrag = annotPointer.moved; + const idx = annotPointer.idx; + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + if (wasDrag) { + // A drag is an intentional reposition; a follow-up click shouldn't be + // interpreted as a double-click-to-delete. + annotLastPinClick = { idx: -1, time: 0 }; + } else { + beginEditPin(idx); + } + e.stopPropagation(); + return; + } + + // kind === 'new' + const wasDrag = annotPointer.moved; + if (wasDrag) { + annotState.strokes.push({ points: annotPointer.strokePoints }); + // Swap the temporary preview SVG path for the full render with hit paths. + redrawStrokes(); + } else { + const idx = annotState.comments.length; + annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' }); + renderAllPins(); + beginEditPin(idx); + } + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + e.stopPropagation(); + } + + function pointsToPath(points) { + if (!points || points.length === 0) return ''; + let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1); + for (let i = 1; i < points.length; i++) { + d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1); + } + return d; + } + + function renderAllPins() { + annotPinsEl.innerHTML = ''; + annotState.comments.forEach((c, idx) => { + annotPinsEl.appendChild(buildPinElement(c, idx)); + }); + updateClearChip(); + } + + function buildPinElement(comment, idx) { + const interactive = idx >= 0; + const wrap = document.createElement('div'); + if (interactive) wrap.dataset.annotPin = String(idx); + Object.assign(wrap.style, { + position: 'absolute', + left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px', + pointerEvents: interactive ? 'auto' : 'none', + display: 'flex', alignItems: 'flex-start', gap: '6px', + cursor: interactive ? 'grab' : 'default', + touchAction: 'none', + }); + const dot = document.createElement('div'); + Object.assign(dot.style, { + width: '14px', height: '14px', borderRadius: '50%', + background: C.brand, border: '2px solid ' + C.white, + boxShadow: '0 1px 3px rgba(0,0,0,0.25)', + flexShrink: '0', + }); + wrap.appendChild(dot); + + if (comment.text) { + const bubble = document.createElement('div'); + bubble.textContent = comment.text; + Object.assign(bubble.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + marginTop: '-2px', maxWidth: '220px', + pointerEvents: 'none', whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }); + wrap.appendChild(bubble); + } + return wrap; + } + + function beginEditPin(idx) { + const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]'); + if (!wrapEl) return; + // Strip any existing bubble (but keep the dot) + wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove()); + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Note…'; + Object.assign(input.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + border: '1px solid ' + C.brand, + outline: 'none', marginTop: '-2px', + width: '220px', pointerEvents: 'auto', + }); + const originalText = annotState.comments[idx].text || ''; + input.value = originalText; + wrapEl.appendChild(input); + annotEditing = { idx, input, wrapEl, originalText }; + input.addEventListener('keydown', onAnnotInputKey, true); + input.addEventListener('blur', () => { + // Fires on both focus-loss and programmatic blur; commit unless we + // already handled it. + if (annotEditing && annotEditing.input === input) finalizeEditingPin(); + }); + // Stop clicks/pointerdowns inside the input from bubbling to the overlay + ['pointerdown', 'click'].forEach(ev => { + input.addEventListener(ev, e => e.stopPropagation()); + }); + setTimeout(() => input.focus(), 0); + } + + function onAnnotInputKey(e) { + if (e.key === 'Enter') { + e.preventDefault(); e.stopPropagation(); + finalizeEditingPin(); + } else if (e.key === 'Escape') { + e.preventDefault(); e.stopPropagation(); + cancelEditingPin(); + } else { + // Keep arrows / backspace from hitting global handlers + e.stopPropagation(); + } + } + + function finalizeEditingPin() { + if (!annotEditing) return; + const { idx, input } = annotEditing; + const text = input.value.trim(); + annotEditing = null; + if (text) annotState.comments[idx].text = text; + else annotState.comments.splice(idx, 1); + renderAllPins(); + } + + function cancelEditingPin() { + if (!annotEditing) return; + const { idx, originalText } = annotEditing; + annotEditing = null; + // If the pin had text before this edit, revert to it. If it was a + // just-created empty pin, Escape removes it. + if (originalText) { + annotState.comments[idx].text = originalText; + } else { + annotState.comments.splice(idx, 1); + } + renderAllPins(); + } + + // Build a detached annotation subtree suitable for injection into the clone + // modern-screenshot creates. Coordinates are element-local so this slots + // straight into an element that's been made position:relative. Takes an + // explicit snapshot so it works after annotState has been cleared. + function buildAnnotationsForCapture(rect, snapshot) { + const comments = snapshot ? snapshot.comments : annotState.comments; + const strokes = snapshot ? snapshot.strokes : annotState.strokes; + if (comments.length === 0 && strokes.length === 0) return null; + const wrap = document.createElement('div'); + Object.assign(wrap.style, { + position: 'absolute', top: '0', left: '0', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', overflow: 'visible', + }); + if (strokes.length > 0) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height); + Object.assign(svg.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', overflow: 'visible', + }); + for (const s of strokes) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('stroke', C.brand); + path.setAttribute('stroke-width', '3'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('fill', 'none'); + path.setAttribute('d', pointsToPath(s.points)); + svg.appendChild(path); + } + wrap.appendChild(svg); + } + for (const c of comments) { + // idx=-1 means non-interactive; pointerEvents stay off in the clone + wrap.appendChild(buildPinElement(c, -1)); + } + return wrap; } // --------------------------------------------------------------------------- @@ -416,8 +901,10 @@ fontSize: '11px', color: C.ash, whiteSpace: 'nowrap', marginLeft: 'auto', }); + // Variants currently arrive atomically in a single file edit, so a + // per-variant counter would lie. Say what's true. status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + (arrivedVariants + 1) + ' of ' + expectedVariants + '...' + ? 'Generating ' + expectedVariants + ' variants...' : 'Done'; row.appendChild(status); @@ -838,6 +1325,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + hideShaderOverlay(); updateBarContent('cycling'); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -860,6 +1348,10 @@ positionBar(); showHighlight(selectedElement); } + if (annotActive) positionAnnotOverlay(selectedElement); + // Shader overlay (via debug P toggle or generation) is repositioned + // by its own branch below; debug no longer has a separate overlay. + if (shaderState) positionShaderOverlay(); scrollRaf = requestAnimationFrame(tick); } scrollRaf = requestAnimationFrame(tick); @@ -937,6 +1429,8 @@ } hideBar(); hideHighlight(); + hideShaderOverlay(); + hideAnnotOverlay(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } clearSession(); @@ -976,6 +1470,8 @@ if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { hideBar(); stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -989,15 +1485,19 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); } function handleKeyDown(e) { + // When the annotation input is focused, let it handle its own keys. + if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); state = 'PICKING'; return; } + if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { hideHighlight(); state = 'IDLE'; return; } @@ -1024,6 +1524,8 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); return; @@ -1035,6 +1537,8 @@ } else { // CONFIGURING: re-select the new element and refresh the bar selectedElement = next; + clearAnnotations(); + showAnnotOverlay(next); showBar('configure'); startScrollTracking(); } @@ -1056,25 +1560,408 @@ const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; + // Commit any pending pin edit BEFORE we snapshot annotations. + if (annotEditing) finalizeEditingPin(); + currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; - sendEvent({ + // Flip to GENERATING immediately so the bar morphs without waiting on + // capture + upload. The event is emitted from captureAndEmit() once the + // screenshot is uploaded (or capture fails — we still emit, just without + // screenshotPath). + const elForCapture = selectedElement; + const captureRect = elForCapture.getBoundingClientRect(); + const snapshot = { + comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), + strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })), + }; + const basePayload = { type: 'generate', id: currentSessionId, action: selectedAction, freeformPrompt: prompt || undefined, count: selectedCount, pageUrl: location.pathname, - element: extractContext(selectedElement), - }); + element: extractContext(elForCapture), + }; + if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments; + if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes; + + // Hide the interactive overlay so it doesn't linger during generation. + hideAnnotOverlay(); + clearAnnotations(); state = 'GENERATING'; showBar('generating'); saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + + captureAndEmit(elForCapture, basePayload, snapshot, captureRect); + } + + // --------------------------------------------------------------------------- + // Screenshot capture + upload + // --------------------------------------------------------------------------- + + let msLoadPromise = null; + function loadModernScreenshot() { + if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot); + if (msLoadPromise) return msLoadPromise; + msLoadPromise = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; + s.onload = () => resolve(window.modernScreenshot); + s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; + document.head.appendChild(s); + }); + return msLoadPromise; + } + + // Collect @font-face rules from every stylesheet on the page. Cross-origin + // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules + // access, so modern-screenshot can't embed them on its own — the resulting + // SVG falls back to system fonts and text re-wraps + renders with different + // weight. We fetch the raw CSS text (CORS-permitted for these providers), + // extract @font-face blocks, inline the referenced font files as base64 + // data URIs (SVGs rasterized via canvas can't fetch external resources, + // so URLs inside the SVG silently fail without this), and pass the result + // to modern-screenshot as font.cssText. + const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i; + const FONT_MIME = { + woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject', + }; + function bufferToBase64(buf) { + const bytes = new Uint8Array(buf); + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); + } + async function inlineFontUrls(cssText) { + const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g; + const urls = new Set(); + let m; + while ((m = urlRe.exec(cssText))) { + if (FONT_EXT_RE.test(m[2])) urls.add(m[2]); + } + const map = new Map(); + await Promise.all([...urls].map(async (url) => { + try { + const res = await fetch(url); + if (!res.ok) return; + const buf = await res.arrayBuffer(); + const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2'; + const mime = FONT_MIME[ext] || 'application/octet-stream'; + map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf)); + } catch { /* skip; fall through to URL */ } + })); + return cssText.replace(urlRe, (orig, q, url) => { + const data = map.get(url); + return data ? 'url(' + q + data + q + ')' : orig; + }); + } + async function collectFontCssText() { + const chunks = []; + const fontFaceRe = /@font-face\s*\{[^}]*\}/g; + for (const sheet of document.styleSheets) { + try { + const rules = sheet.cssRules; + for (const rule of rules) { + if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) { + chunks.push(rule.cssText); + } + } + } catch { + if (!sheet.href) continue; + try { + const res = await fetch(sheet.href); + if (!res.ok) continue; + const text = await res.text(); + let m2; + while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]); + } catch { /* ignore; capture is best-effort */ } + } + } + if (chunks.length === 0) return ''; + return inlineFontUrls(chunks.join('\n')); + } + + // Capture the element (with current annotations baked in) and return a PNG + // Blob. Shared between the Go flow (uploads it to the server) and the + // debug toggle (displays it as an overlay for side-by-side comparison). + async function captureElementToBlob(el, snapshot, rect) { + try { if (document.fonts?.ready) await document.fonts.ready; } catch {} + const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); + let annotNode = null; + let savedPosition = null; + if (hasAnnotations) { + const pos = getComputedStyle(el).position; + if (pos === 'static') { + savedPosition = el.style.position; + el.style.position = 'relative'; + } + annotNode = buildAnnotationsForCapture(rect, snapshot); + el.appendChild(annotNode); + } + try { + const ms = await loadModernScreenshot(); + const fontCssText = await collectFontCssText(); + return await ms.domToBlob(el, { + scale: Math.min(window.devicePixelRatio || 1, 2), + backgroundColor: getComputedStyle(document.body).backgroundColor || '#ffffff', + font: fontCssText ? { cssText: fontCssText } : undefined, + }); + } finally { + if (annotNode) annotNode.remove(); + if (savedPosition !== null) el.style.position = savedPosition; + } + } + + async function captureAndEmit(el, basePayload, snapshot, rect) { + let screenshotPath; + let blob; + try { + blob = await captureElementToBlob(el, snapshot, rect); + } catch (err) { + console.warn('[impeccable] capture failed, proceeding without screenshot:', err); + } + // Light up the shader overlay the moment capture is ready — no reason to + // wait for the upload to complete before the user sees something alive. + if (blob && state === 'GENERATING') { + showShaderOverlay(el, blob, rect); + } + if (blob) { + try { + const uploadRes = await fetch( + 'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) + + '&eventId=' + encodeURIComponent(basePayload.id), + { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob }, + ); + if (uploadRes.ok) { + const { path: p } = await uploadRes.json(); + screenshotPath = p; + } else { + console.warn('[impeccable] annotation upload failed:', uploadRes.status); + } + } catch (err) { + console.warn('[impeccable] annotation upload failed:', err); + } + } + sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); + } + + // --------------------------------------------------------------------------- + // Shader overlay — renders the captured screenshot as a WebGL texture and + // runs an editorial "ink-wash" fragment shader over it during generation. + // A single rolling band sweeps top-to-bottom, desaturating + tinting magenta + // and leaving a soft trail. Makes the wait feel like a letterpress scan + // instead of a dead spinner. + // --------------------------------------------------------------------------- + + const SHADER_VS = `attribute vec2 a_position; +attribute vec2 a_uv; +varying vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_position, 0.0, 1.0); +}`; + + const SHADER_FS = `precision highp float; +uniform sampler2D u_texture; +uniform float u_time; +uniform vec2 u_resolution; +uniform vec3 u_accent; +varying vec2 v_uv; + +// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at +// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean +// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below" +// failure that reversed-edge smoothstep would give). +float bandAt(float d, float leadW, float trailW) { + float above = smoothstep(-leadW, 0.0, d); + float below = 1.0 - smoothstep(0.0, trailW, d); + return above * below; +} + +void main() { + vec2 uv = v_uv; + // Roller sweeps top-to-bottom with small overshoot so each cycle enters + // and exits the element cleanly. + float phase = fract(u_time / 3.4); + float y = phase * 1.25 - 0.12; + float band = bandAt(uv.y - y, 0.05, 0.32); + + // Halftone cell grid (fixed ~10 px pitch). + float cellPx = 10.0; + vec2 gridUv = uv * u_resolution / cellPx; + vec2 cellId = floor(gridUv); + vec2 cellUv = fract(gridUv) - 0.5; + vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution; + vec3 cellImg = texture2D(u_texture, sampleCenter).rgb; + float luma = dot(cellImg, vec3(0.299, 0.587, 0.114)); + // Darker cells → bigger magenta dots (classic risograph halftone curve). + float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56; + float dotMask = smoothstep(radius + 0.06, radius, length(cellUv)); + vec3 paper = vec3(0.975, 0.965, 0.955); + vec3 dotLayer = mix(paper, u_accent, dotMask); + + // Blend the halftone layer in where the roller is passing; leave the + // element pristine elsewhere. + vec3 base = texture2D(u_texture, uv).rgb; + gl_FragColor = vec4(mix(base, dotLayer, band), 1.0); +}`; + + // Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350)) + const SHADER_ACCENT = [0.82, 0.16, 0.47]; + let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + + function compileShader(gl, type, source) { + const sh = gl.createShader(type); + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(sh); + gl.deleteShader(sh); + throw new Error('shader compile failed: ' + info); + } + return sh; + } + + function positionShaderOverlay() { + if (!shaderState || !selectedElement) return; + const r = selectedElement.getBoundingClientRect(); + Object.assign(shaderState.canvas.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + } + + function hideShaderOverlay() { + if (!shaderState) return; + if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); + if (shaderState.canvas) shaderState.canvas.remove(); + const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + shaderState = null; + } + + async function showShaderOverlay(el, blob, rect) { + hideShaderOverlay(); + if (!blob || !el) return; + const canvas = document.createElement('canvas'); + canvas.id = PREFIX + '-shader'; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + Object.assign(canvas.style, { + position: 'fixed', + top: rect.top + 'px', left: rect.left + 'px', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', + zIndex: Z.bar - 1, + }); + document.body.appendChild(canvas); + + const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) + || canvas.getContext('experimental-webgl'); + if (!gl) { + // WebGL unavailable — fall back to a plain overlay so the user + // still sees something meaningful during generation. + canvas.remove(); + const img = document.createElement('img'); + img.src = URL.createObjectURL(blob); + img.id = PREFIX + '-shader'; + Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' }); + document.body.appendChild(img); + shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 }; + return; + } + + let program, texture; + try { + const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS); + program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error('program link failed: ' + gl.getProgramInfoLog(program)); + } + // Full-screen quad + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + -1, -1, 0, 1, + 1, -1, 1, 1, + -1, 1, 0, 0, + -1, 1, 0, 0, + 1, -1, 1, 1, + 1, 1, 1, 0, + ]), gl.STATIC_DRAW); + const posLoc = gl.getAttribLocation(program, 'a_position'); + const uvLoc = gl.getAttribLocation(program, 'a_uv'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(uvLoc); + gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8); + } catch (err) { + console.warn('[impeccable] shader setup failed:', err); + canvas.remove(); + return; + } + + // Upload the screenshot as a texture + let bitmap; + try { + bitmap = await createImageBitmap(blob); + } catch { + // Safari fallback: go via a regular Image + const imgUrl = URL.createObjectURL(blob); + const img = new Image(); + img.src = imgUrl; + await new Promise((r, rej) => { img.onload = r; img.onerror = rej; }); + bitmap = img; + URL.revokeObjectURL(imgUrl); + } + texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + if (bitmap.close) bitmap.close(); + + const uTime = gl.getUniformLocation(program, 'u_time'); + const uRes = gl.getUniformLocation(program, 'u_resolution'); + const uAccent = gl.getUniformLocation(program, 'u_accent'); + const uTex = gl.getUniformLocation(program, 'u_texture'); + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; + function frame() { + if (!shaderState) return; + const elapsed = (performance.now() - shaderState.startTime) / 1000; + const t = shaderState.reduced ? 0.0 : elapsed; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.useProgram(program); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.uniform1i(uTex, 0); + gl.uniform1f(uTime, t); + gl.uniform2f(uRes, canvas.width, canvas.height); + gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]); + gl.drawArrays(gl.TRIANGLES, 0, 6); + shaderState.rafId = requestAnimationFrame(frame); + } + frame(); } function handleAccept() { @@ -2713,6 +3600,7 @@ function init() { initHighlight(); + initAnnotOverlay(); initBar(); initActionPicker(); initGlobalBar(); diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs index 109aa768e..97163b255 100644 --- a/.agents/skills/impeccable/scripts/live-server.mjs +++ b/.agents/skills/impeccable/scripts/live-server.mjs @@ -18,7 +18,6 @@ import { randomUUID } from 'node:crypto'; import { spawn, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; @@ -56,8 +55,13 @@ const state = { pendingEvents: [], // browser events waiting for agent poll pendingPolls: [], // agent poll callbacks waiting for browser events exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots }; +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + function enqueueEvent(event) { if (state.pendingPolls.length > 0) { state.pendingPolls.shift()(event); @@ -134,6 +138,10 @@ function validateEvent(msg) { if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; return null; case 'accept': if (!msg.id) return 'accept: missing id'; @@ -175,6 +183,83 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { return; } + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + // --- Health --- if (p === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -250,10 +335,11 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } const absPath = path.resolve(process.cwd(), filePath); if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } - try { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(fs.readFileSync(absPath, 'utf-8')); - } catch { res.writeHead(404); res.end('File not found'); } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); return; } @@ -411,6 +497,9 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } for (const res of state.sseClients) { try { res.end(); } catch {} } state.sseClients.clear(); for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); @@ -442,12 +531,14 @@ Options: --help Show this help Endpoints: - /live.js Browser script (element picker + variant cycling) - /detect.js Detection overlay (backwards compatible) - /events SSE stream (server→browser) + POST (browser→server) - /poll Long-poll for agent CLI - /source Raw source file reader (no-HMR fallback) - /health Health check`); + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); process.exit(0); } @@ -531,6 +622,12 @@ try { state.token = randomUUID(); const portArg = args.find(a => a.startsWith('--port=')); state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); const { detectScript, liveScript } = loadBrowserScripts(); const liveScriptWithToken = diff --git a/.agents/skills/impeccable/scripts/modern-screenshot.umd.js b/.agents/skills/impeccable/scripts/modern-screenshot.umd.js new file mode 100644 index 000000000..a9c5208f6 --- /dev/null +++ b/.agents/skills/impeccable/scripts/modern-screenshot.umd.js @@ -0,0 +1,14 @@ +(function(y,v){typeof exports=="object"&&typeof module!="undefined"?v(exports):typeof define=="function"&&define.amd?define(["exports"],v):(y=typeof globalThis!="undefined"?globalThis:y||self,v(y.modernScreenshot={}))})(this,function(y){"use strict";var rr=Object.defineProperty,nr=Object.defineProperties;var or=Object.getOwnPropertyDescriptors;var Z=Object.getOwnPropertySymbols;var xe=Object.prototype.hasOwnProperty,Me=Object.prototype.propertyIsEnumerable;var Oe=Math.pow,Le=(y,v,N)=>v in y?rr(y,v,{enumerable:!0,configurable:!0,writable:!0,value:N}):y[v]=N,D=(y,v)=>{for(var N in v||(v={}))xe.call(v,N)&&Le(y,N,v[N]);if(Z)for(var N of Z(v))Me.call(v,N)&&Le(y,N,v[N]);return y},M=(y,v)=>nr(y,or(v));var je=(y,v)=>{var N={};for(var R in y)xe.call(y,R)&&v.indexOf(R)<0&&(N[R]=y[R]);if(y!=null&&Z)for(var R of Z(y))v.indexOf(R)<0&&Me.call(y,R)&&(N[R]=y[R]);return N};var C=(y,v,N)=>new Promise((R,O)=>{var X=P=>{try{q(N.next(P))}catch(W){O(W)}},j=P=>{try{q(N.throw(P))}catch(W){O(W)}},q=P=>P.done?R(P.value):Promise.resolve(P.value).then(X,j);q((N=N.apply(y,v)).next())});var Be;function v(e,t){return e[13]=1,e[14]=t>>8,e[15]=t&255,e[16]=t>>8,e[17]=t&255,e}const N=112,R=72,O=89,X=115;let j;function q(){const e=new Int32Array(256);for(let t=0;t<256;t++){let r=t;for(let n=0;n<8;n++)r=r&1?3988292384^r>>>1:r>>>1;e[t]=r}return e}function P(e){let t=-1;j||(j=q());for(let r=0;r>>8;return t^-1}function W(e){const t=e.length-1;for(let r=t;r>=4;r--)if(e[r-4]===9&&e[r-3]===N&&e[r-2]===R&&e[r-1]===O&&e[r]===X)return r-3;return 0}function ae(e,t,r=!1){const n=new Uint8Array(13);t*=39.3701,n[0]=N,n[1]=R,n[2]=O,n[3]=X,n[4]=t>>>24,n[5]=t>>>16,n[6]=t>>>8,n[7]=t&255,n[8]=n[4],n[9]=n[5],n[10]=n[6],n[11]=n[7],n[12]=1;const i=P(n),a=new Uint8Array(4);if(a[0]=i>>>24,a[1]=i>>>16,a[2]=i>>>8,a[3]=i&255,r){const s=W(e);return e.set(n,s),e.set(a,s+13),e}else{const s=new Uint8Array(4);s[0]=0,s[1]=0,s[2]=0,s[3]=9;const o=new Uint8Array(54);return o.set(e,0),o.set(s,33),o.set(n,37),o.set(a,50),o}}const qe="AAlwSFlz",We="AAAJcEhZ",He="AAAACXBI";function Ve(e){let t=e.indexOf(qe);return t===-1&&(t=e.indexOf(We)),t===-1&&(t=e.indexOf(He)),t}const se="[modern-screenshot]",U=typeof window!="undefined",ze=U&&"Worker"in window,ie=U&&"atob"in window,Xe=U&&"btoa"in window,ee=U?(Be=window.navigator)==null?void 0:Be.userAgent:"",le=ee.includes("Chrome"),G=ee.includes("AppleWebKit")&&!le,te=ee.includes("Firefox"),Ge=e=>e&&"__CONTEXT__"in e,Ye=e=>e.constructor.name==="CSSFontFaceRule",Je=e=>e.constructor.name==="CSSImportRule",Ke=e=>e.constructor.name==="CSSLayerBlockRule",I=e=>e.nodeType===1,H=e=>typeof e.className=="object",ce=e=>e.tagName==="image",Qe=e=>e.tagName==="use",V=e=>I(e)&&typeof e.style!="undefined"&&!H(e),Ze=e=>e.nodeType===8,et=e=>e.nodeType===3,$=e=>e.tagName==="IMG",Y=e=>e.tagName==="VIDEO",tt=e=>e.tagName==="CANVAS",rt=e=>e.tagName==="TEXTAREA",nt=e=>e.tagName==="INPUT",ot=e=>e.tagName==="STYLE",at=e=>e.tagName==="SCRIPT",st=e=>e.tagName==="SELECT",it=e=>e.tagName==="SLOT",lt=e=>e.tagName==="IFRAME",ct=(...e)=>console.warn(se,...e);function ut(e){var r;const t=(r=e==null?void 0:e.createElement)==null?void 0:r.call(e,"canvas");return t&&(t.height=t.width=1),!!t&&"toDataURL"in t&&!!t.toDataURL("image/webp").includes("image/webp")}const re=e=>e.startsWith("data:");function ue(e,t){if(e.match(/^[a-z]+:\/\//i))return e;if(U&&e.match(/^\/\//))return window.location.protocol+e;if(e.match(/^[a-z]+:/i)||!U)return e;const r=J().implementation.createHTMLDocument(),n=r.createElement("base"),i=r.createElement("a");return r.head.appendChild(n),r.body.appendChild(i),t&&(n.href=t),i.href=e,i.href}function J(e){var t;return(t=e&&I(e)?e==null?void 0:e.ownerDocument:e)!=null?t:window.document}const K="http://www.w3.org/2000/svg";function fe(e,t,r){const n=J(r).createElementNS(K,"svg");return n.setAttributeNS(null,"width",e.toString()),n.setAttributeNS(null,"height",t.toString()),n.setAttributeNS(null,"viewBox",`0 0 ${e} ${t}`),n}function de(e,t){let r=new XMLSerializer().serializeToString(e);return t&&(r=r.replace(/[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu,"")),`data:image/svg+xml;charset=utf-8,${encodeURIComponent(r)}`}function ft(e,t="image/png",r=1){return C(this,null,function*(){try{return yield new Promise((n,i)=>{e.toBlob(a=>{a?n(a):i(new Error("Blob is null"))},t,r)})}catch(n){if(ie)return dt(e.toDataURL(t,r));throw n}})}function dt(e){var o,c;const[t,r]=e.split(","),n=(c=(o=t.match(/data:(.+);/))==null?void 0:o[1])!=null?c:void 0,i=window.atob(r),a=i.length,s=new Uint8Array(a);for(let u=0;u{const i=new FileReader;i.onload=()=>r(i.result),i.onerror=()=>n(i.error),i.onabort=()=>n(new Error(`Failed read blob to ${t}`)),t==="dataUrl"?i.readAsDataURL(e):t==="arrayBuffer"&&i.readAsArrayBuffer(e)})}const gt=e=>ge(e,"dataUrl"),mt=e=>ge(e,"arrayBuffer");function _(e,t){const r=J(t).createElement("img");return r.decoding="sync",r.loading="eager",r.src=e,r}function L(e,t){return new Promise(r=>{const{timeout:n,ownerDocument:i,onError:a,onWarn:s}=t!=null?t:{},o=typeof e=="string"?_(e,J(i)):e;let c=null,u=null;function l(){r(o),c&&clearTimeout(c),u==null||u()}if(n&&(c=setTimeout(l,n)),Y(o)){const d=o.currentSrc||o.src;if(!d)return o.poster?L(o.poster,t).then(r):l();if(o.readyState>=2)return l();const m=l,f=h=>{s==null||s("Failed video load",d,h),a==null||a(h),l()};u=()=>{o.removeEventListener("loadeddata",m),o.removeEventListener("error",f)},o.addEventListener("loadeddata",m,{once:!0}),o.addEventListener("error",f,{once:!0})}else{const d=ce(o)?o.href.baseVal:o.currentSrc||o.src;if(!d)return l();const m=()=>C(this,null,function*(){if($(o)&&"decode"in o)try{yield o.decode()}catch(h){s==null||s("Failed to decode image, trying to render anyway",o.dataset.originalSrc||d,h)}l()}),f=h=>{s==null||s("Failed image load",o.dataset.originalSrc||d,h),l()};if($(o)&&o.complete)return m();u=()=>{o.removeEventListener("load",m),o.removeEventListener("error",f)},o.addEventListener("load",m,{once:!0}),o.addEventListener("error",f,{once:!0})}})}function me(e,t){return C(this,null,function*(){V(e)&&($(e)||Y(e)?yield L(e,t):yield Promise.all(["img","video"].flatMap(r=>Array.from(e.querySelectorAll(r)).map(n=>L(n,t)))))})}const he=function(){let t=0;const r=()=>`0000${(Math.random()*Oe(36,4)<<0).toString(36)}`.slice(-4);return()=>(t+=1,`u${r()}${t}`)}();function we(e){return e==null?void 0:e.split(",").map(t=>t.trim().replace(/"|'/g,"").toLowerCase()).filter(Boolean)}let pe=0;function ht(e){const t=`${se}[#${pe}]`;return pe++,{time:r=>e&&console.time(`${t} ${r}`),timeEnd:r=>e&&console.timeEnd(`${t} ${r}`),warn:(...r)=>e&&ct(...r)}}function wt(e){return{cache:e?"no-cache":"force-cache"}}function k(e,t){return C(this,null,function*(){return Ge(e)?e:ye(e,M(D({},t),{autoDestruct:!0}))})}function ye(e,t){return C(this,null,function*(){var f,h,g,p,E;const{scale:r=1,workerUrl:n,workerNumber:i=1}=t||{},a=!!(t!=null&&t.debug),s=(f=t==null?void 0:t.features)!=null?f:!0,o=(h=e.ownerDocument)!=null?h:U?window.document:void 0,c=(p=(g=e.ownerDocument)==null?void 0:g.defaultView)!=null?p:U?window:void 0,u=new Map,l=M(D({width:0,height:0,quality:1,type:"image/png",scale:r,backgroundColor:null,style:null,filter:null,maximumCanvasSize:0,timeout:3e4,progress:null,debug:a,fetch:D({requestInit:wt((E=t==null?void 0:t.fetch)==null?void 0:E.bypassingCache),placeholderImage:"data:image/png;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",bypassingCache:!1},t==null?void 0:t.fetch),fetchFn:null,font:{},drawImageInterval:100,workerUrl:null,workerNumber:i,onCloneEachNode:null,onCloneNode:null,onEmbedNode:null,onCreateForeignObjectSvg:null,includeStyleProperties:null,autoDestruct:!1},t),{__CONTEXT__:!0,log:ht(a),node:e,ownerDocument:o,ownerWindow:c,dpi:r===1?null:96*r,svgStyleElement:be(o),svgDefsElement:o==null?void 0:o.createElementNS(K,"defs"),svgStyles:new Map,defaultComputedStyles:new Map,workers:[...Array.from({length:ze&&n&&i?i:0})].map(()=>{try{const b=new Worker(n);return b.onmessage=w=>C(this,null,function*(){var A,F,B,$e;const{url:S,result:T}=w.data;T?(F=(A=u.get(S))==null?void 0:A.resolve)==null||F.call(A,T):($e=(B=u.get(S))==null?void 0:B.reject)==null||$e.call(B,new Error(`Error receiving message from worker: ${S}`))}),b.onmessageerror=w=>{var T,A;const{url:S}=w.data;(A=(T=u.get(S))==null?void 0:T.reject)==null||A.call(T,new Error(`Error receiving message from worker: ${S}`))},b}catch(b){return l.log.warn("Failed to new Worker",b),null}}).filter(Boolean),fontFamilies:new Map,fontCssTexts:new Map,acceptOfImage:`${[ut(o)&&"image/webp","image/svg+xml","image/*","*/*"].filter(Boolean).join(",")};q=0.8`,requests:u,drawImageCount:0,tasks:[],features:s,isEnable:b=>{var w,S;return b==="restoreScrollPosition"?typeof s=="boolean"?!1:(w=s[b])!=null?w:!1:typeof s=="boolean"?s:(S=s[b])!=null?S:!0},shadowRoots:[]});l.log.time("wait until load"),yield me(e,{timeout:l.timeout,onWarn:l.log.warn}),l.log.timeEnd("wait until load");const{width:d,height:m}=pt(e,l);return l.width=d,l.height=m,l})}function be(e){if(!e)return;const t=e.createElement("style"),r=t.ownerDocument.createTextNode(` +.______background-clip--text { + background-clip: text; + -webkit-background-clip: text; +} +`);return t.appendChild(r),t}function pt(e,t){let{width:r,height:n}=t;if(I(e)&&(!r||!n)){const i=e.getBoundingClientRect();r=r||i.width||Number(e.getAttribute("width"))||0,n=n||i.height||Number(e.getAttribute("height"))||0}return{width:r,height:n}}function yt(e,t){return C(this,null,function*(){const{log:r,timeout:n,drawImageCount:i,drawImageInterval:a}=t;r.time("image to canvas");const s=yield L(e,{timeout:n,onWarn:t.log.warn}),{canvas:o,context2d:c}=bt(e.ownerDocument,t),u=()=>{try{c==null||c.drawImage(s,0,0,o.width,o.height)}catch(l){t.log.warn("Failed to drawImage",l)}};if(u(),t.isEnable("fixSvgXmlDecode"))for(let l=0;l{setTimeout(()=>{c==null||c.clearRect(0,0,o.width,o.height),u(),d()},l+a)});return t.drawImageCount=0,r.timeEnd("image to canvas"),o})}function bt(e,t){const{width:r,height:n,scale:i,backgroundColor:a,maximumCanvasSize:s}=t,o=e.createElement("canvas");o.width=Math.floor(r*i),o.height=Math.floor(n*i),o.style.width=`${r}px`,o.style.height=`${n}px`,s&&(o.width>s||o.height>s)&&(o.width>s&&o.height>s?o.width>o.height?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s):o.width>s?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s));const c=o.getContext("2d");return c&&a&&(c.fillStyle=a,c.fillRect(0,0,o.width,o.height)),{canvas:o,context2d:c}}function Se(e,t){if(e.ownerDocument)try{const a=e.toDataURL();if(a!=="data:,")return _(a,e.ownerDocument)}catch(a){t.log.warn("Failed to clone canvas",a)}const r=e.cloneNode(!1),n=e.getContext("2d"),i=r.getContext("2d");try{return n&&i&&i.putImageData(n.getImageData(0,0,e.width,e.height),0,0),r}catch(a){t.log.warn("Failed to clone canvas",a)}return r}function St(e,t){var r;try{if((r=e==null?void 0:e.contentDocument)!=null&&r.documentElement)return ne(e.contentDocument.documentElement,t)}catch(n){t.log.warn("Failed to clone iframe",n)}return e.cloneNode(!1)}function Et(e){const t=e.cloneNode(!1);return e.currentSrc&&e.currentSrc!==e.src&&(t.src=e.currentSrc,t.srcset=""),t.loading==="lazy"&&(t.loading="eager"),t}function Ct(e,t){return C(this,null,function*(){if(e.ownerDocument&&!e.currentSrc&&e.poster)return _(e.poster,e.ownerDocument);const r=e.cloneNode(!1);r.crossOrigin="anonymous",e.currentSrc&&e.currentSrc!==e.src&&(r.src=e.currentSrc);const n=r.ownerDocument;if(n){let i=!0;if(yield L(r,{onError:()=>i=!1,onWarn:t.log.warn}),!i)return e.poster?_(e.poster,e.ownerDocument):r;r.currentTime=e.currentTime,yield new Promise(s=>{r.addEventListener("seeked",s,{once:!0})});const a=n.createElement("canvas");a.width=e.offsetWidth,a.height=e.offsetHeight;try{const s=a.getContext("2d");s&&s.drawImage(r,0,0,a.width,a.height)}catch(s){return t.log.warn("Failed to clone video",s),e.poster?_(e.poster,e.ownerDocument):r}return Se(a,t)}return r})}function Tt(e,t){return tt(e)?Se(e,t):lt(e)?St(e,t):$(e)?Et(e):Y(e)?Ct(e,t):e.cloneNode(!1)}function vt(e){let t=e.sandbox;if(!t){const{ownerDocument:r}=e;try{r&&(t=r.createElement("iframe"),t.id=`__SANDBOX__${he()}`,t.width="0",t.height="0",t.style.visibility="hidden",t.style.position="fixed",r.body.appendChild(t),t.srcdoc='',e.sandbox=t)}catch(n){e.log.warn("Failed to getSandBox",n)}}return t}const At=["width","height","-webkit-text-fill-color"],Nt=["stroke","fill"];function Ee(e,t,r){const{defaultComputedStyles:n}=r,i=e.nodeName.toLowerCase(),a=H(e)&&i!=="svg",s=a?Nt.map(g=>[g,e.getAttribute(g)]).filter(([,g])=>g!==null):[],o=[a&&"svg",i,s.map((g,p)=>`${g}=${p}`).join(","),t].filter(Boolean).join(":");if(n.has(o))return n.get(o);const c=vt(r),u=c==null?void 0:c.contentWindow;if(!u)return new Map;const l=u==null?void 0:u.document;let d,m;a?(d=l.createElementNS(K,"svg"),m=d.ownerDocument.createElementNS(d.namespaceURI,i),s.forEach(([g,p])=>{m.setAttributeNS(null,g,p)}),d.appendChild(m)):d=m=l.createElement(i),m.textContent=" ",l.body.appendChild(d);const f=u.getComputedStyle(m,t),h=new Map;for(let g=f.length,p=0;pn.set(d,l));function s(c){const u=e.getPropertyValue(c),l=e.getPropertyPriority(c),d=c.lastIndexOf("-"),m=d>-1?c.substring(0,d):void 0;if(m){let f=a.get(m);f||(f=new Map,a.set(m,f)),f.set(c,[u,l])}t.get(c)===u&&!l||(m?i.push(m):n.set(c,[u,l]))}return n}function Rt(e,t,r,n){var d,m,f,h;const{ownerWindow:i,includeStyleProperties:a,currentParentNodeStyle:s}=n,o=t.style,c=i.getComputedStyle(e),u=Ee(e,null,n);s==null||s.forEach((g,p)=>{u.delete(p)});const l=Ce(c,u,a);l.delete("transition-property"),l.delete("all"),l.delete("d"),l.delete("content"),r&&(l.delete("position"),l.delete("margin-top"),l.delete("margin-right"),l.delete("margin-bottom"),l.delete("margin-left"),l.delete("margin-block-start"),l.delete("margin-block-end"),l.delete("margin-inline-start"),l.delete("margin-inline-end"),l.set("box-sizing",["border-box",""])),((d=l.get("background-clip"))==null?void 0:d[0])==="text"&&t.classList.add("______background-clip--text"),le&&(l.has("font-kerning")||l.set("font-kerning",["normal",""]),(((m=l.get("overflow-x"))==null?void 0:m[0])==="hidden"||((f=l.get("overflow-y"))==null?void 0:f[0])==="hidden")&&((h=l.get("text-overflow"))==null?void 0:h[0])==="ellipsis"&&e.scrollWidth===e.clientWidth&&l.set("text-overflow",["clip",""]));for(let g=o.length,p=0;p{o.setProperty(E,g,p)}),l}function It(e,t){(rt(e)||nt(e)||st(e))&&t.setAttribute("value",e.value)}const kt=["::before","::after"],Dt=["::-webkit-scrollbar","::-webkit-scrollbar-button","::-webkit-scrollbar-thumb","::-webkit-scrollbar-track","::-webkit-scrollbar-track-piece","::-webkit-scrollbar-corner","::-webkit-resizer"];function Pt(e,t,r,n,i){const{ownerWindow:a,svgStyleElement:s,svgStyles:o,currentNodeStyle:c}=n;if(!s||!a)return;function u(l){var w;const d=a.getComputedStyle(e,l);let m=d.getPropertyValue("content");if(!m||m==="none")return;i==null||i(m),m=m.replace(/(')|(")|(counter\(.+\))/g,"");const f=[he()],h=Ee(e,l,n);c==null||c.forEach((S,T)=>{h.delete(T)});const g=Ce(d,h,n.includeStyleProperties);g.delete("content"),g.delete("-webkit-locale"),((w=g.get("background-clip"))==null?void 0:w[0])==="text"&&t.classList.add("______background-clip--text");const p=[`content: '${m}';`];if(g.forEach(([S,T],A)=>{p.push(`${A}: ${S}${T?" !important":""};`)}),p.length===1)return;try{t.className=[t.className,...f].join(" ")}catch(S){n.log.warn("Failed to copyPseudoClass",S);return}const E=p.join(` + `);let b=o.get(E);b||(b=[],o.set(E,b)),b.push(`.${f[0]}${l}`)}kt.forEach(u),r&&Dt.forEach(u)}const Te=new Set(["symbol"]);function ve(e,t,r,n,i){return C(this,null,function*(){if(I(r)&&(ot(r)||at(r))||n.filter&&!n.filter(r))return;Te.has(t.nodeName)||Te.has(r.nodeName)?n.currentParentNodeStyle=void 0:n.currentParentNodeStyle=n.currentNodeStyle;const a=yield ne(r,n,!1,i);n.isEnable("restoreScrollPosition")&&Ut(e,a),t.appendChild(a)})}function Ae(e,t,r,n){return C(this,null,function*(){var a;let i=e.firstChild;I(e)&&e.shadowRoot&&(i=(a=e.shadowRoot)==null?void 0:a.firstChild,r.shadowRoots.push(e.shadowRoot));for(let s=i;s;s=s.nextSibling)if(!Ze(s))if(I(s)&&it(s)&&typeof s.assignedNodes=="function"){const o=s.assignedNodes();for(let c=0;ce.clientHeight||e.scrollWidth>e.clientWidth)}const p=(d=h.get("text-transform"))==null?void 0:d[0],E=we((m=h.get("font-family"))==null?void 0:m[0]),b=E?w=>{p==="uppercase"?w=w.toUpperCase():p==="lowercase"?w=w.toLowerCase():p==="capitalize"&&(w=w[0].toUpperCase()+w.substring(1)),E.forEach(S=>{let T=s.get(S);T||s.set(S,T=new Set),w.split("").forEach(A=>T.add(A))})}:void 0;return Pt(e,f,g,t,b),It(e,f),Y(e)||(yield Ae(e,f,t,b)),yield o==null?void 0:o(f),f}const c=e.cloneNode(!1);return yield Ae(e,c,t),yield o==null?void 0:o(c),c})}function Ne(e){if(e.ownerDocument=void 0,e.ownerWindow=void 0,e.svgStyleElement=void 0,e.svgDefsElement=void 0,e.svgStyles.clear(),e.defaultComputedStyles.clear(),e.sandbox){try{e.sandbox.remove()}catch(t){e.log.warn("Failed to destroyContext",t)}e.sandbox=void 0}e.workers=[],e.fontFamilies.clear(),e.fontCssTexts.clear(),e.requests.clear(),e.tasks=[],e.shadowRoots=[]}function Bt(e){const o=e,{url:t,timeout:r,responseType:n}=o,i=je(o,["url","timeout","responseType"]),a=new AbortController,s=r?setTimeout(()=>a.abort(),r):void 0;return fetch(t,D({signal:a.signal},i)).then(c=>{if(!c.ok)throw new Error("Failed fetch, not 2xx response",{cause:c});switch(n){case"arrayBuffer":return c.arrayBuffer();case"dataUrl":return c.blob().then(gt);case"text":default:return c.text()}}).finally(()=>clearTimeout(s))}function z(e,t){const{url:r,requestType:n="text",responseType:i="text",imageDom:a}=t;let s=r;const{timeout:o,acceptOfImage:c,requests:u,fetchFn:l,fetch:{requestInit:d,bypassingCache:m,placeholderImage:f},font:h,workers:g,fontFamilies:p}=e;n==="image"&&(G||te)&&e.drawImageCount++;let E=u.get(r);if(!E){m&&m instanceof RegExp&&m.test(s)&&(s+=(/\?/.test(s)?"&":"?")+new Date().getTime());const b=n.startsWith("font")&&h&&h.minify,w=new Set;b&&n.split(";")[1].split(",").forEach(F=>{p.has(F)&&p.get(F).forEach(B=>w.add(B))});const S=b&&w.size,T=D({url:s,timeout:o,responseType:S?"arrayBuffer":i,headers:n==="image"?{accept:c}:void 0},d);E={type:n,resolve:void 0,reject:void 0,response:null},E.response=C(this,null,function*(){if(l&&n==="image"){const A=yield l(r);if(A)return A}return!G&&r.startsWith("http")&&g.length?new Promise((A,F)=>{g[u.size&g.length-1].postMessage(D({rawUrl:r},T)),E.resolve=A,E.reject=F}):Bt(T)}).catch(A=>{if(u.delete(r),n==="image"&&f)return e.log.warn("Failed to fetch image base64, trying to use placeholder image",s),typeof f=="string"?f:f(a);throw A}),u.set(r,E)}return E.response}function Re(e,t,r,n){return C(this,null,function*(){if(!Ie(e))return e;for(const[i,a]of $t(e,t))try{const s=yield z(r,{url:a,requestType:n?"image":"text",responseType:"dataUrl"});e=e.replace(Lt(i),`$1${s}$3`)}catch(s){r.log.warn("Failed to fetch css data url",i,s)}return e})}function Ie(e){return/url\((['"]?)([^'"]+?)\1\)/.test(e)}const ke=/url\((['"]?)([^'"]+?)\1\)/g;function $t(e,t){const r=[];return e.replace(ke,(n,i,a)=>(r.push([a,ue(a,t)]),n)),r.filter(([n])=>!re(n))}function Lt(e){const t=e.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${t})(['"]?\\))`,"g")}const xt=["background-image","border-image-source","-webkit-border-image","-webkit-mask-image","list-style-image"];function Mt(e,t){return xt.map(r=>{const n=e.getPropertyValue(r);return!n||n==="none"?null:((G||te)&&t.drawImageCount++,Re(n,null,t,!0).then(i=>{!i||n===i||e.setProperty(r,i,e.getPropertyPriority(r))}))}).filter(Boolean)}function Ot(e,t){if($(e)){const r=e.currentSrc||e.src;if(!re(r))return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.srcset="",e.dataset.originalSrc=r,e.src=n||"")})];(G||te)&&t.drawImageCount++}else if(H(e)&&!re(e.href.baseVal)){const r=e.href.baseVal;return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.dataset.originalSrc=r,e.href.baseVal=n||"")})]}return[]}function jt(e,t){var o;const{ownerDocument:r,svgDefsElement:n}=t,i=(o=e.getAttribute("href"))!=null?o:e.getAttribute("xlink:href");if(!i)return[];const[a,s]=i.split("#");if(s){const c=`#${s}`,u=t.shadowRoots.reduce((l,d)=>l!=null?l:d.querySelector(`svg ${c}`),r==null?void 0:r.querySelector(`svg ${c}`));if(a&&e.setAttribute("href",c),n!=null&&n.querySelector(c))return[];if(u)return n==null||n.appendChild(u.cloneNode(!0)),[];if(a)return[z(t,{url:a,responseType:"text"}).then(l=>{n==null||n.insertAdjacentHTML("beforeend",l)})]}return[]}function De(e,t){const{tasks:r}=t;I(e)&&(($(e)||ce(e))&&r.push(...Ot(e,t)),Qe(e)&&r.push(...jt(e,t))),V(e)&&r.push(...Mt(e.style,t)),e.childNodes.forEach(n=>{De(n,t)})}function qt(e,t){return C(this,null,function*(){const{ownerDocument:r,svgStyleElement:n,fontFamilies:i,fontCssTexts:a,tasks:s,font:o}=t;if(!(!r||!n||!i.size))if(o&&o.cssText){const c=Ue(o.cssText,t);n.appendChild(r.createTextNode(`${c} +`))}else{const c=Array.from(r.styleSheets).filter(f=>{try{return"cssRules"in f&&!!f.cssRules.length}catch(h){return t.log.warn(`Error while reading CSS rules from ${f.href}`,h),!1}}),u=r.implementation.createHTMLDocument(""),l=u.createElement("style");u.head.appendChild(l);const d=l.sheet;yield Promise.all(c.flatMap(f=>Array.from(f.cssRules).map(h=>C(this,null,function*(){if(Je(h)){const g=h.href;let p="";try{p=yield z(t,{url:g,requestType:"text",responseType:"text"})}catch(b){t.log.warn(`Error fetch remote css import from ${g}`,b)}const E=p.replace(ke,(b,w,S)=>b.replace(S,ue(S,g)));for(const b of Ht(E))try{d.insertRule(b,d.cssRules.length)}catch(w){t.log.warn("Error inserting rule from remote css import",{rule:b,error:w})}}})))),d.cssRules.length&&c.push(d);const m=[];c.forEach(f=>{oe(f.cssRules,m)}),m.filter(f=>{var h;return Ye(f)&&Ie(f.style.getPropertyValue("src"))&&((h=we(f.style.getPropertyValue("font-family")))==null?void 0:h.some(g=>i.has(g)))}).forEach(f=>{const h=f,g=a.get(h.cssText);g?n.appendChild(r.createTextNode(`${g} +`)):s.push(Re(h.cssText,h.parentStyleSheet?h.parentStyleSheet.href:null,t).then(p=>{p=Ue(p,t),a.set(h.cssText,p),n.appendChild(r.createTextNode(`${p} +`))}))})}})}const Wt=/(\/\*[\s\S]*?\*\/)/g,Pe=/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi;function Ht(e){if(e==null)return[];const t=[];let r=e.replace(Wt,"");for(;;){const a=Pe.exec(r);if(!a)break;t.push(a[0])}r=r.replace(Pe,"");const n=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,i=new RegExp("((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})","gi");for(;;){let a=n.exec(r);if(a)i.lastIndex=n.lastIndex;else if(a=i.exec(r),a)n.lastIndex=i.lastIndex;else break;t.push(a[0])}return t}const Vt=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,zt=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Ue(e,t){const{font:r}=t,n=r?r==null?void 0:r.preferredFormat:void 0;return n?e.replace(zt,i=>{for(;;){const[a,,s]=Vt.exec(i)||[];if(!s)return"";if(s===n)return`src: ${a};`}}):e}function oe(e,t=[]){for(const r of Array.from(e))Ke(r)?t.push(...oe(r.cssRules)):"cssRules"in r?oe(r.cssRules,t):t.push(r);return t}const Xt=/\bx?link:?href\s*=\s*["'](?!data:)[^"']+["']/i;function Gt(e){return Xt.test(e.innerHTML)}function _e(e,t){return C(this,null,function*(){const r=yield k(e,t);if(I(r.node)&&H(r.node)&&!Gt(r.node))return r.node;const{ownerDocument:n,log:i,tasks:a,svgStyleElement:s,svgDefsElement:o,svgStyles:c,font:u,progress:l,autoDestruct:d,onCloneNode:m,onEmbedNode:f,onCreateForeignObjectSvg:h}=r;i.time("clone node");const g=yield ne(r.node,r,!0);if(s&&n){let S="";c.forEach((T,A)=>{S+=`${T.join(`, +`)} { + ${A} +} +`}),s.appendChild(n.createTextNode(S))}i.timeEnd("clone node"),yield m==null?void 0:m(g),u!==!1&&I(g)&&(i.time("embed web font"),yield qt(g,r),i.timeEnd("embed web font")),i.time("embed node"),De(g,r);const p=a.length;let E=0;const b=()=>C(this,null,function*(){for(;;){const S=a.pop();if(!S)break;try{yield S}catch(T){r.log.warn("Failed to run task",T)}l==null||l(++E,p)}});l==null||l(E,p),yield Promise.all([...Array.from({length:4})].map(b)),i.timeEnd("embed node"),yield f==null?void 0:f(g);const w=Yt(g,r);return o&&w.insertBefore(o,w.children[0]),s&&w.insertBefore(s,w.children[0]),d&&Ne(r),yield h==null?void 0:h(w),w})}function Yt(e,t){const{width:r,height:n}=t,i=fe(r,n,e.ownerDocument),a=i.ownerDocument.createElementNS(i.namespaceURI,"foreignObject");return a.setAttributeNS(null,"x","0%"),a.setAttributeNS(null,"y","0%"),a.setAttributeNS(null,"width","100%"),a.setAttributeNS(null,"height","100%"),a.append(e),i.appendChild(a),i}function Q(e,t){return C(this,null,function*(){var s;const r=yield k(e,t),n=yield _e(r),i=de(n,r.isEnable("removeControlCharacter"));r.autoDestruct||(r.svgStyleElement=be(r.ownerDocument),r.svgDefsElement=(s=r.ownerDocument)==null?void 0:s.createElementNS(K,"defs"),r.svgStyles.clear());const a=_(i,n.ownerDocument);return yield yt(a,r)})}function Jt(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,type:i,quality:a,dpi:s}=r,o=yield Q(r);n.time("canvas to blob");const c=yield ft(o,i,a);if(["image/png","image/jpeg"].includes(i)&&s){const u=yield mt(c.slice(0,33));let l=new Uint8Array(u);return i==="image/png"?l=ae(l,s):i==="image/jpeg"&&(l=v(l,s)),n.timeEnd("canvas to blob"),new Blob([l,c.slice(33)],{type:i})}return n.timeEnd("canvas to blob"),c})}function x(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,quality:i,type:a,dpi:s}=r,o=yield Q(r);n.time("canvas to data url");let c=o.toDataURL(a,i);if(["image/png","image/jpeg"].includes(a)&&s&&ie&&Xe){const[u,l]=c.split(",");let d=0,m=!1;if(a==="image/png"){const w=Ve(l);w>=0?(d=Math.ceil((w+28)/3)*4,m=!0):d=33/3*4}else a==="image/jpeg"&&(d=18/3*4);const f=l.substring(0,d),h=l.substring(d),g=window.atob(f),p=new Uint8Array(g.length);for(let w=0;w mirroring selectedElement's bounding rect. Click (no + // drag) drops a comment pin; drag paints a magenta SVG stroke. All coords + // are stored in element-local CSS px so they survive scroll / resize and + // correlate directly with the captured PNG. + // --------------------------------------------------------------------------- + + const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click + const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it + let annotOverlayEl = null; + let annotSvgEl = null; + let annotPinsEl = null; + let annotClearChipEl = null; + let annotState = { comments: [], strokes: [] }; + let annotActive = false; + // `annotPointer` is either: + // { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin + // { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin + let annotPointer = null; + let annotEditing = null; // { idx, input, wrapEl } + let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete + + function initAnnotOverlay() { + annotOverlayEl = document.createElement('div'); + annotOverlayEl.id = PREFIX + '-annot'; + Object.assign(annotOverlayEl.style, { + position: 'fixed', top: '0', left: '0', width: '0', height: '0', + pointerEvents: 'auto', zIndex: Z.highlight + 2, + display: 'none', overflow: 'visible', + cursor: 'crosshair', touchAction: 'none', + }); + + annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + annotSvgEl.id = PREFIX + '-annot-svg'; + Object.assign(annotSvgEl.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', + // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via + // pointer-events=stroke so gaps still fall through to the overlay. + pointerEvents: 'none', overflow: 'visible', + }); + annotOverlayEl.appendChild(annotSvgEl); + + annotPinsEl = document.createElement('div'); + annotPinsEl.id = PREFIX + '-annot-pins'; + Object.assign(annotPinsEl.style, { + position: 'absolute', inset: '0', + pointerEvents: 'none', + }); + annotOverlayEl.appendChild(annotPinsEl); + + annotClearChipEl = document.createElement('div'); + annotClearChipEl.id = PREFIX + '-annot-clear'; + annotClearChipEl.dataset.annotClear = 'true'; + annotClearChipEl.textContent = 'Clear'; + Object.assign(annotClearChipEl.style, { + position: 'absolute', top: '8px', right: '8px', + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '10px', fontWeight: '500', + letterSpacing: '0.08em', textTransform: 'uppercase', + padding: '5px 12px', borderRadius: '999px', + cursor: 'pointer', pointerEvents: 'auto', + display: 'none', userSelect: 'none', + boxShadow: '0 1px 3px rgba(0,0,0,0.2)', + }); + annotOverlayEl.appendChild(annotClearChipEl); + + annotOverlayEl.addEventListener('pointerdown', onAnnotDown); + annotOverlayEl.addEventListener('pointermove', onAnnotMove); + annotOverlayEl.addEventListener('pointerup', onAnnotUp); + annotOverlayEl.addEventListener('pointercancel', onAnnotUp); + document.body.appendChild(annotOverlayEl); + } + + function updateClearChip() { + if (!annotClearChipEl) return; + const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0; + annotClearChipEl.style.display = hasAny ? 'block' : 'none'; + } + + function showAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + annotActive = true; + positionAnnotOverlay(el); + annotOverlayEl.style.display = 'block'; + } + + function hideAnnotOverlay() { + annotActive = false; + if (annotOverlayEl) annotOverlayEl.style.display = 'none'; + // Drop any in-progress edit without touching annotState — clearAnnotations + // (if the caller is exiting configure mode) handles state reset. + annotEditing = null; + } + + function positionAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + const r = el.getBoundingClientRect(); + Object.assign(annotOverlayEl.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height); + } + + function clearAnnotations() { + annotState.comments = []; + annotState.strokes = []; + if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + if (annotPinsEl) annotPinsEl.innerHTML = ''; + annotPointer = null; + annotEditing = null; + annotLastPinClick = { idx: -1, time: 0 }; + updateClearChip(); + } + + // Rebuild the SVG layer. Each stroke gets a wider invisible hit path + // beneath the visible magenta path so clicks register on thin lines. + function redrawStrokes() { + while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + annotState.strokes.forEach((s, idx) => { + const d = pointsToPath(s.points); + const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + hit.setAttribute('d', d); + hit.setAttribute('stroke', 'transparent'); + hit.setAttribute('stroke-width', '16'); + hit.setAttribute('stroke-linecap', 'round'); + hit.setAttribute('stroke-linejoin', 'round'); + hit.setAttribute('fill', 'none'); + hit.setAttribute('pointer-events', 'stroke'); + hit.style.cursor = 'pointer'; + hit.dataset.annotStroke = String(idx); + annotSvgEl.appendChild(hit); + const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + visible.setAttribute('d', d); + visible.setAttribute('stroke', C.brand); + visible.setAttribute('stroke-width', '3'); + visible.setAttribute('stroke-linecap', 'round'); + visible.setAttribute('stroke-linejoin', 'round'); + visible.setAttribute('fill', 'none'); + visible.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(visible); + }); + updateClearChip(); + } + + function localCoords(e) { + const rect = annotOverlayEl.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + function onAnnotDown(e) { + if (!annotActive) return; + + // 1) Clear chip → wipe all annotations + if (e.target.closest?.('[data-annot-clear]')) { + if (annotEditing) annotEditing = null; + clearAnnotations(); + renderAllPins(); + redrawStrokes(); + e.stopPropagation(); e.preventDefault(); + return; + } + + // 2) Stroke hit path → delete that stroke + const strokeHit = e.target.closest?.('[data-annot-stroke]'); + if (strokeHit) { + const idx = parseInt(strokeHit.dataset.annotStroke, 10); + if (Number.isInteger(idx)) { + annotState.strokes.splice(idx, 1); + redrawStrokes(); + } + e.stopPropagation(); e.preventDefault(); + return; + } + + // 3) Pin → drag, edit, or delete-on-double-click + const pinWrap = e.target.closest?.('[data-annot-pin]'); + if (pinWrap) { + const idx = parseInt(pinWrap.dataset.annotPin, 10); + if (!Number.isInteger(idx)) return; + // Double-click (two pointerdowns on the same pin within window) → delete. + const now = Date.now(); + if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) { + if (annotEditing && annotEditing.idx === idx) annotEditing = null; + annotState.comments.splice(idx, 1); + annotLastPinClick = { idx: -1, time: 0 }; + renderAllPins(); + e.stopPropagation(); e.preventDefault(); + return; + } + annotLastPinClick = { idx, time: now }; + // If editing a different pin, commit that edit before starting here. + if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin(); + // If already editing THIS pin and the user clicked the dot, let the + // input keep focus (don't start a drag — the click wasn't meant as one). + if (annotEditing && annotEditing.idx === idx) return; + const p = localCoords(e); + const pin = annotState.comments[idx]; + annotPointer = { + kind: 'pin', idx, + startPointer: p, + startPin: { x: pin.x, y: pin.y }, + moved: false, + }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + return; + } + + // 4) Empty area → commit any open edit, then start new annotation + if (annotEditing) { + finalizeEditingPin(); + e.stopPropagation(); e.preventDefault(); + return; + } + const p = localCoords(e); + annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + } + + function onAnnotMove(e) { + if (!annotActive || !annotPointer) return; + const p = localCoords(e); + + if (annotPointer.kind === 'pin') { + const dx = p.x - annotPointer.startPointer.x; + const dy = p.y - annotPointer.startPointer.y; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + } + const pin = annotState.comments[annotPointer.idx]; + if (!pin) { annotPointer = null; return; } + pin.x = annotPointer.startPin.x + dx; + pin.y = annotPointer.startPin.y + dy; + renderAllPins(); + e.stopPropagation(); + return; + } + + // kind === 'new' + const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + strokeEl.setAttribute('stroke', C.brand); + strokeEl.setAttribute('stroke-width', '3'); + strokeEl.setAttribute('stroke-linecap', 'round'); + strokeEl.setAttribute('stroke-linejoin', 'round'); + strokeEl.setAttribute('fill', 'none'); + strokeEl.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(strokeEl); + annotPointer.strokeEl = strokeEl; + annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]]; + } + annotPointer.strokePoints.push([p.x, p.y]); + annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints)); + e.stopPropagation(); + } + + function onAnnotUp(e) { + if (!annotActive || !annotPointer) return; + + if (annotPointer.kind === 'pin') { + const wasDrag = annotPointer.moved; + const idx = annotPointer.idx; + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + if (wasDrag) { + // A drag is an intentional reposition; a follow-up click shouldn't be + // interpreted as a double-click-to-delete. + annotLastPinClick = { idx: -1, time: 0 }; + } else { + beginEditPin(idx); + } + e.stopPropagation(); + return; + } + + // kind === 'new' + const wasDrag = annotPointer.moved; + if (wasDrag) { + annotState.strokes.push({ points: annotPointer.strokePoints }); + // Swap the temporary preview SVG path for the full render with hit paths. + redrawStrokes(); + } else { + const idx = annotState.comments.length; + annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' }); + renderAllPins(); + beginEditPin(idx); + } + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + e.stopPropagation(); + } + + function pointsToPath(points) { + if (!points || points.length === 0) return ''; + let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1); + for (let i = 1; i < points.length; i++) { + d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1); + } + return d; + } + + function renderAllPins() { + annotPinsEl.innerHTML = ''; + annotState.comments.forEach((c, idx) => { + annotPinsEl.appendChild(buildPinElement(c, idx)); + }); + updateClearChip(); + } + + function buildPinElement(comment, idx) { + const interactive = idx >= 0; + const wrap = document.createElement('div'); + if (interactive) wrap.dataset.annotPin = String(idx); + Object.assign(wrap.style, { + position: 'absolute', + left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px', + pointerEvents: interactive ? 'auto' : 'none', + display: 'flex', alignItems: 'flex-start', gap: '6px', + cursor: interactive ? 'grab' : 'default', + touchAction: 'none', + }); + const dot = document.createElement('div'); + Object.assign(dot.style, { + width: '14px', height: '14px', borderRadius: '50%', + background: C.brand, border: '2px solid ' + C.white, + boxShadow: '0 1px 3px rgba(0,0,0,0.25)', + flexShrink: '0', + }); + wrap.appendChild(dot); + + if (comment.text) { + const bubble = document.createElement('div'); + bubble.textContent = comment.text; + Object.assign(bubble.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + marginTop: '-2px', maxWidth: '220px', + pointerEvents: 'none', whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }); + wrap.appendChild(bubble); + } + return wrap; + } + + function beginEditPin(idx) { + const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]'); + if (!wrapEl) return; + // Strip any existing bubble (but keep the dot) + wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove()); + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Note…'; + Object.assign(input.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + border: '1px solid ' + C.brand, + outline: 'none', marginTop: '-2px', + width: '220px', pointerEvents: 'auto', + }); + const originalText = annotState.comments[idx].text || ''; + input.value = originalText; + wrapEl.appendChild(input); + annotEditing = { idx, input, wrapEl, originalText }; + input.addEventListener('keydown', onAnnotInputKey, true); + input.addEventListener('blur', () => { + // Fires on both focus-loss and programmatic blur; commit unless we + // already handled it. + if (annotEditing && annotEditing.input === input) finalizeEditingPin(); + }); + // Stop clicks/pointerdowns inside the input from bubbling to the overlay + ['pointerdown', 'click'].forEach(ev => { + input.addEventListener(ev, e => e.stopPropagation()); + }); + setTimeout(() => input.focus(), 0); + } + + function onAnnotInputKey(e) { + if (e.key === 'Enter') { + e.preventDefault(); e.stopPropagation(); + finalizeEditingPin(); + } else if (e.key === 'Escape') { + e.preventDefault(); e.stopPropagation(); + cancelEditingPin(); + } else { + // Keep arrows / backspace from hitting global handlers + e.stopPropagation(); + } + } + + function finalizeEditingPin() { + if (!annotEditing) return; + const { idx, input } = annotEditing; + const text = input.value.trim(); + annotEditing = null; + if (text) annotState.comments[idx].text = text; + else annotState.comments.splice(idx, 1); + renderAllPins(); + } + + function cancelEditingPin() { + if (!annotEditing) return; + const { idx, originalText } = annotEditing; + annotEditing = null; + // If the pin had text before this edit, revert to it. If it was a + // just-created empty pin, Escape removes it. + if (originalText) { + annotState.comments[idx].text = originalText; + } else { + annotState.comments.splice(idx, 1); + } + renderAllPins(); + } + + // Build a detached annotation subtree suitable for injection into the clone + // modern-screenshot creates. Coordinates are element-local so this slots + // straight into an element that's been made position:relative. Takes an + // explicit snapshot so it works after annotState has been cleared. + function buildAnnotationsForCapture(rect, snapshot) { + const comments = snapshot ? snapshot.comments : annotState.comments; + const strokes = snapshot ? snapshot.strokes : annotState.strokes; + if (comments.length === 0 && strokes.length === 0) return null; + const wrap = document.createElement('div'); + Object.assign(wrap.style, { + position: 'absolute', top: '0', left: '0', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', overflow: 'visible', + }); + if (strokes.length > 0) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height); + Object.assign(svg.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', overflow: 'visible', + }); + for (const s of strokes) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('stroke', C.brand); + path.setAttribute('stroke-width', '3'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('fill', 'none'); + path.setAttribute('d', pointsToPath(s.points)); + svg.appendChild(path); + } + wrap.appendChild(svg); + } + for (const c of comments) { + // idx=-1 means non-interactive; pointerEvents stay off in the clone + wrap.appendChild(buildPinElement(c, -1)); + } + return wrap; } // --------------------------------------------------------------------------- @@ -416,8 +901,10 @@ fontSize: '11px', color: C.ash, whiteSpace: 'nowrap', marginLeft: 'auto', }); + // Variants currently arrive atomically in a single file edit, so a + // per-variant counter would lie. Say what's true. status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + (arrivedVariants + 1) + ' of ' + expectedVariants + '...' + ? 'Generating ' + expectedVariants + ' variants...' : 'Done'; row.appendChild(status); @@ -838,6 +1325,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + hideShaderOverlay(); updateBarContent('cycling'); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -860,6 +1348,10 @@ positionBar(); showHighlight(selectedElement); } + if (annotActive) positionAnnotOverlay(selectedElement); + // Shader overlay (via debug P toggle or generation) is repositioned + // by its own branch below; debug no longer has a separate overlay. + if (shaderState) positionShaderOverlay(); scrollRaf = requestAnimationFrame(tick); } scrollRaf = requestAnimationFrame(tick); @@ -937,6 +1429,8 @@ } hideBar(); hideHighlight(); + hideShaderOverlay(); + hideAnnotOverlay(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } clearSession(); @@ -976,6 +1470,8 @@ if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { hideBar(); stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -989,15 +1485,19 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); } function handleKeyDown(e) { + // When the annotation input is focused, let it handle its own keys. + if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); state = 'PICKING'; return; } + if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { hideHighlight(); state = 'IDLE'; return; } @@ -1024,6 +1524,8 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); return; @@ -1035,6 +1537,8 @@ } else { // CONFIGURING: re-select the new element and refresh the bar selectedElement = next; + clearAnnotations(); + showAnnotOverlay(next); showBar('configure'); startScrollTracking(); } @@ -1056,25 +1560,408 @@ const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; + // Commit any pending pin edit BEFORE we snapshot annotations. + if (annotEditing) finalizeEditingPin(); + currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; - sendEvent({ + // Flip to GENERATING immediately so the bar morphs without waiting on + // capture + upload. The event is emitted from captureAndEmit() once the + // screenshot is uploaded (or capture fails — we still emit, just without + // screenshotPath). + const elForCapture = selectedElement; + const captureRect = elForCapture.getBoundingClientRect(); + const snapshot = { + comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), + strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })), + }; + const basePayload = { type: 'generate', id: currentSessionId, action: selectedAction, freeformPrompt: prompt || undefined, count: selectedCount, pageUrl: location.pathname, - element: extractContext(selectedElement), - }); + element: extractContext(elForCapture), + }; + if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments; + if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes; + + // Hide the interactive overlay so it doesn't linger during generation. + hideAnnotOverlay(); + clearAnnotations(); state = 'GENERATING'; showBar('generating'); saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + + captureAndEmit(elForCapture, basePayload, snapshot, captureRect); + } + + // --------------------------------------------------------------------------- + // Screenshot capture + upload + // --------------------------------------------------------------------------- + + let msLoadPromise = null; + function loadModernScreenshot() { + if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot); + if (msLoadPromise) return msLoadPromise; + msLoadPromise = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; + s.onload = () => resolve(window.modernScreenshot); + s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; + document.head.appendChild(s); + }); + return msLoadPromise; + } + + // Collect @font-face rules from every stylesheet on the page. Cross-origin + // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules + // access, so modern-screenshot can't embed them on its own — the resulting + // SVG falls back to system fonts and text re-wraps + renders with different + // weight. We fetch the raw CSS text (CORS-permitted for these providers), + // extract @font-face blocks, inline the referenced font files as base64 + // data URIs (SVGs rasterized via canvas can't fetch external resources, + // so URLs inside the SVG silently fail without this), and pass the result + // to modern-screenshot as font.cssText. + const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i; + const FONT_MIME = { + woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject', + }; + function bufferToBase64(buf) { + const bytes = new Uint8Array(buf); + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); + } + async function inlineFontUrls(cssText) { + const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g; + const urls = new Set(); + let m; + while ((m = urlRe.exec(cssText))) { + if (FONT_EXT_RE.test(m[2])) urls.add(m[2]); + } + const map = new Map(); + await Promise.all([...urls].map(async (url) => { + try { + const res = await fetch(url); + if (!res.ok) return; + const buf = await res.arrayBuffer(); + const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2'; + const mime = FONT_MIME[ext] || 'application/octet-stream'; + map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf)); + } catch { /* skip; fall through to URL */ } + })); + return cssText.replace(urlRe, (orig, q, url) => { + const data = map.get(url); + return data ? 'url(' + q + data + q + ')' : orig; + }); + } + async function collectFontCssText() { + const chunks = []; + const fontFaceRe = /@font-face\s*\{[^}]*\}/g; + for (const sheet of document.styleSheets) { + try { + const rules = sheet.cssRules; + for (const rule of rules) { + if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) { + chunks.push(rule.cssText); + } + } + } catch { + if (!sheet.href) continue; + try { + const res = await fetch(sheet.href); + if (!res.ok) continue; + const text = await res.text(); + let m2; + while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]); + } catch { /* ignore; capture is best-effort */ } + } + } + if (chunks.length === 0) return ''; + return inlineFontUrls(chunks.join('\n')); + } + + // Capture the element (with current annotations baked in) and return a PNG + // Blob. Shared between the Go flow (uploads it to the server) and the + // debug toggle (displays it as an overlay for side-by-side comparison). + async function captureElementToBlob(el, snapshot, rect) { + try { if (document.fonts?.ready) await document.fonts.ready; } catch {} + const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); + let annotNode = null; + let savedPosition = null; + if (hasAnnotations) { + const pos = getComputedStyle(el).position; + if (pos === 'static') { + savedPosition = el.style.position; + el.style.position = 'relative'; + } + annotNode = buildAnnotationsForCapture(rect, snapshot); + el.appendChild(annotNode); + } + try { + const ms = await loadModernScreenshot(); + const fontCssText = await collectFontCssText(); + return await ms.domToBlob(el, { + scale: Math.min(window.devicePixelRatio || 1, 2), + backgroundColor: getComputedStyle(document.body).backgroundColor || '#ffffff', + font: fontCssText ? { cssText: fontCssText } : undefined, + }); + } finally { + if (annotNode) annotNode.remove(); + if (savedPosition !== null) el.style.position = savedPosition; + } + } + + async function captureAndEmit(el, basePayload, snapshot, rect) { + let screenshotPath; + let blob; + try { + blob = await captureElementToBlob(el, snapshot, rect); + } catch (err) { + console.warn('[impeccable] capture failed, proceeding without screenshot:', err); + } + // Light up the shader overlay the moment capture is ready — no reason to + // wait for the upload to complete before the user sees something alive. + if (blob && state === 'GENERATING') { + showShaderOverlay(el, blob, rect); + } + if (blob) { + try { + const uploadRes = await fetch( + 'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) + + '&eventId=' + encodeURIComponent(basePayload.id), + { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob }, + ); + if (uploadRes.ok) { + const { path: p } = await uploadRes.json(); + screenshotPath = p; + } else { + console.warn('[impeccable] annotation upload failed:', uploadRes.status); + } + } catch (err) { + console.warn('[impeccable] annotation upload failed:', err); + } + } + sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); + } + + // --------------------------------------------------------------------------- + // Shader overlay — renders the captured screenshot as a WebGL texture and + // runs an editorial "ink-wash" fragment shader over it during generation. + // A single rolling band sweeps top-to-bottom, desaturating + tinting magenta + // and leaving a soft trail. Makes the wait feel like a letterpress scan + // instead of a dead spinner. + // --------------------------------------------------------------------------- + + const SHADER_VS = `attribute vec2 a_position; +attribute vec2 a_uv; +varying vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_position, 0.0, 1.0); +}`; + + const SHADER_FS = `precision highp float; +uniform sampler2D u_texture; +uniform float u_time; +uniform vec2 u_resolution; +uniform vec3 u_accent; +varying vec2 v_uv; + +// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at +// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean +// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below" +// failure that reversed-edge smoothstep would give). +float bandAt(float d, float leadW, float trailW) { + float above = smoothstep(-leadW, 0.0, d); + float below = 1.0 - smoothstep(0.0, trailW, d); + return above * below; +} + +void main() { + vec2 uv = v_uv; + // Roller sweeps top-to-bottom with small overshoot so each cycle enters + // and exits the element cleanly. + float phase = fract(u_time / 3.4); + float y = phase * 1.25 - 0.12; + float band = bandAt(uv.y - y, 0.05, 0.32); + + // Halftone cell grid (fixed ~10 px pitch). + float cellPx = 10.0; + vec2 gridUv = uv * u_resolution / cellPx; + vec2 cellId = floor(gridUv); + vec2 cellUv = fract(gridUv) - 0.5; + vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution; + vec3 cellImg = texture2D(u_texture, sampleCenter).rgb; + float luma = dot(cellImg, vec3(0.299, 0.587, 0.114)); + // Darker cells → bigger magenta dots (classic risograph halftone curve). + float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56; + float dotMask = smoothstep(radius + 0.06, radius, length(cellUv)); + vec3 paper = vec3(0.975, 0.965, 0.955); + vec3 dotLayer = mix(paper, u_accent, dotMask); + + // Blend the halftone layer in where the roller is passing; leave the + // element pristine elsewhere. + vec3 base = texture2D(u_texture, uv).rgb; + gl_FragColor = vec4(mix(base, dotLayer, band), 1.0); +}`; + + // Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350)) + const SHADER_ACCENT = [0.82, 0.16, 0.47]; + let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + + function compileShader(gl, type, source) { + const sh = gl.createShader(type); + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(sh); + gl.deleteShader(sh); + throw new Error('shader compile failed: ' + info); + } + return sh; + } + + function positionShaderOverlay() { + if (!shaderState || !selectedElement) return; + const r = selectedElement.getBoundingClientRect(); + Object.assign(shaderState.canvas.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + } + + function hideShaderOverlay() { + if (!shaderState) return; + if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); + if (shaderState.canvas) shaderState.canvas.remove(); + const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + shaderState = null; + } + + async function showShaderOverlay(el, blob, rect) { + hideShaderOverlay(); + if (!blob || !el) return; + const canvas = document.createElement('canvas'); + canvas.id = PREFIX + '-shader'; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + Object.assign(canvas.style, { + position: 'fixed', + top: rect.top + 'px', left: rect.left + 'px', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', + zIndex: Z.bar - 1, + }); + document.body.appendChild(canvas); + + const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) + || canvas.getContext('experimental-webgl'); + if (!gl) { + // WebGL unavailable — fall back to a plain overlay so the user + // still sees something meaningful during generation. + canvas.remove(); + const img = document.createElement('img'); + img.src = URL.createObjectURL(blob); + img.id = PREFIX + '-shader'; + Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' }); + document.body.appendChild(img); + shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 }; + return; + } + + let program, texture; + try { + const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS); + program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error('program link failed: ' + gl.getProgramInfoLog(program)); + } + // Full-screen quad + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + -1, -1, 0, 1, + 1, -1, 1, 1, + -1, 1, 0, 0, + -1, 1, 0, 0, + 1, -1, 1, 1, + 1, 1, 1, 0, + ]), gl.STATIC_DRAW); + const posLoc = gl.getAttribLocation(program, 'a_position'); + const uvLoc = gl.getAttribLocation(program, 'a_uv'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(uvLoc); + gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8); + } catch (err) { + console.warn('[impeccable] shader setup failed:', err); + canvas.remove(); + return; + } + + // Upload the screenshot as a texture + let bitmap; + try { + bitmap = await createImageBitmap(blob); + } catch { + // Safari fallback: go via a regular Image + const imgUrl = URL.createObjectURL(blob); + const img = new Image(); + img.src = imgUrl; + await new Promise((r, rej) => { img.onload = r; img.onerror = rej; }); + bitmap = img; + URL.revokeObjectURL(imgUrl); + } + texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + if (bitmap.close) bitmap.close(); + + const uTime = gl.getUniformLocation(program, 'u_time'); + const uRes = gl.getUniformLocation(program, 'u_resolution'); + const uAccent = gl.getUniformLocation(program, 'u_accent'); + const uTex = gl.getUniformLocation(program, 'u_texture'); + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; + function frame() { + if (!shaderState) return; + const elapsed = (performance.now() - shaderState.startTime) / 1000; + const t = shaderState.reduced ? 0.0 : elapsed; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.useProgram(program); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.uniform1i(uTex, 0); + gl.uniform1f(uTime, t); + gl.uniform2f(uRes, canvas.width, canvas.height); + gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]); + gl.drawArrays(gl.TRIANGLES, 0, 6); + shaderState.rafId = requestAnimationFrame(frame); + } + frame(); } function handleAccept() { @@ -2713,6 +3600,7 @@ function init() { initHighlight(); + initAnnotOverlay(); initBar(); initActionPicker(); initGlobalBar(); diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs index 109aa768e..97163b255 100644 --- a/.claude/skills/impeccable/scripts/live-server.mjs +++ b/.claude/skills/impeccable/scripts/live-server.mjs @@ -18,7 +18,6 @@ import { randomUUID } from 'node:crypto'; import { spawn, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; @@ -56,8 +55,13 @@ const state = { pendingEvents: [], // browser events waiting for agent poll pendingPolls: [], // agent poll callbacks waiting for browser events exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots }; +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + function enqueueEvent(event) { if (state.pendingPolls.length > 0) { state.pendingPolls.shift()(event); @@ -134,6 +138,10 @@ function validateEvent(msg) { if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; return null; case 'accept': if (!msg.id) return 'accept: missing id'; @@ -175,6 +183,83 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { return; } + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + // --- Health --- if (p === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -250,10 +335,11 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } const absPath = path.resolve(process.cwd(), filePath); if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } - try { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(fs.readFileSync(absPath, 'utf-8')); - } catch { res.writeHead(404); res.end('File not found'); } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); return; } @@ -411,6 +497,9 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } for (const res of state.sseClients) { try { res.end(); } catch {} } state.sseClients.clear(); for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); @@ -442,12 +531,14 @@ Options: --help Show this help Endpoints: - /live.js Browser script (element picker + variant cycling) - /detect.js Detection overlay (backwards compatible) - /events SSE stream (server→browser) + POST (browser→server) - /poll Long-poll for agent CLI - /source Raw source file reader (no-HMR fallback) - /health Health check`); + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); process.exit(0); } @@ -531,6 +622,12 @@ try { state.token = randomUUID(); const portArg = args.find(a => a.startsWith('--port=')); state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); const { detectScript, liveScript } = loadBrowserScripts(); const liveScriptWithToken = diff --git a/.claude/skills/impeccable/scripts/modern-screenshot.umd.js b/.claude/skills/impeccable/scripts/modern-screenshot.umd.js new file mode 100644 index 000000000..a9c5208f6 --- /dev/null +++ b/.claude/skills/impeccable/scripts/modern-screenshot.umd.js @@ -0,0 +1,14 @@ +(function(y,v){typeof exports=="object"&&typeof module!="undefined"?v(exports):typeof define=="function"&&define.amd?define(["exports"],v):(y=typeof globalThis!="undefined"?globalThis:y||self,v(y.modernScreenshot={}))})(this,function(y){"use strict";var rr=Object.defineProperty,nr=Object.defineProperties;var or=Object.getOwnPropertyDescriptors;var Z=Object.getOwnPropertySymbols;var xe=Object.prototype.hasOwnProperty,Me=Object.prototype.propertyIsEnumerable;var Oe=Math.pow,Le=(y,v,N)=>v in y?rr(y,v,{enumerable:!0,configurable:!0,writable:!0,value:N}):y[v]=N,D=(y,v)=>{for(var N in v||(v={}))xe.call(v,N)&&Le(y,N,v[N]);if(Z)for(var N of Z(v))Me.call(v,N)&&Le(y,N,v[N]);return y},M=(y,v)=>nr(y,or(v));var je=(y,v)=>{var N={};for(var R in y)xe.call(y,R)&&v.indexOf(R)<0&&(N[R]=y[R]);if(y!=null&&Z)for(var R of Z(y))v.indexOf(R)<0&&Me.call(y,R)&&(N[R]=y[R]);return N};var C=(y,v,N)=>new Promise((R,O)=>{var X=P=>{try{q(N.next(P))}catch(W){O(W)}},j=P=>{try{q(N.throw(P))}catch(W){O(W)}},q=P=>P.done?R(P.value):Promise.resolve(P.value).then(X,j);q((N=N.apply(y,v)).next())});var Be;function v(e,t){return e[13]=1,e[14]=t>>8,e[15]=t&255,e[16]=t>>8,e[17]=t&255,e}const N=112,R=72,O=89,X=115;let j;function q(){const e=new Int32Array(256);for(let t=0;t<256;t++){let r=t;for(let n=0;n<8;n++)r=r&1?3988292384^r>>>1:r>>>1;e[t]=r}return e}function P(e){let t=-1;j||(j=q());for(let r=0;r>>8;return t^-1}function W(e){const t=e.length-1;for(let r=t;r>=4;r--)if(e[r-4]===9&&e[r-3]===N&&e[r-2]===R&&e[r-1]===O&&e[r]===X)return r-3;return 0}function ae(e,t,r=!1){const n=new Uint8Array(13);t*=39.3701,n[0]=N,n[1]=R,n[2]=O,n[3]=X,n[4]=t>>>24,n[5]=t>>>16,n[6]=t>>>8,n[7]=t&255,n[8]=n[4],n[9]=n[5],n[10]=n[6],n[11]=n[7],n[12]=1;const i=P(n),a=new Uint8Array(4);if(a[0]=i>>>24,a[1]=i>>>16,a[2]=i>>>8,a[3]=i&255,r){const s=W(e);return e.set(n,s),e.set(a,s+13),e}else{const s=new Uint8Array(4);s[0]=0,s[1]=0,s[2]=0,s[3]=9;const o=new Uint8Array(54);return o.set(e,0),o.set(s,33),o.set(n,37),o.set(a,50),o}}const qe="AAlwSFlz",We="AAAJcEhZ",He="AAAACXBI";function Ve(e){let t=e.indexOf(qe);return t===-1&&(t=e.indexOf(We)),t===-1&&(t=e.indexOf(He)),t}const se="[modern-screenshot]",U=typeof window!="undefined",ze=U&&"Worker"in window,ie=U&&"atob"in window,Xe=U&&"btoa"in window,ee=U?(Be=window.navigator)==null?void 0:Be.userAgent:"",le=ee.includes("Chrome"),G=ee.includes("AppleWebKit")&&!le,te=ee.includes("Firefox"),Ge=e=>e&&"__CONTEXT__"in e,Ye=e=>e.constructor.name==="CSSFontFaceRule",Je=e=>e.constructor.name==="CSSImportRule",Ke=e=>e.constructor.name==="CSSLayerBlockRule",I=e=>e.nodeType===1,H=e=>typeof e.className=="object",ce=e=>e.tagName==="image",Qe=e=>e.tagName==="use",V=e=>I(e)&&typeof e.style!="undefined"&&!H(e),Ze=e=>e.nodeType===8,et=e=>e.nodeType===3,$=e=>e.tagName==="IMG",Y=e=>e.tagName==="VIDEO",tt=e=>e.tagName==="CANVAS",rt=e=>e.tagName==="TEXTAREA",nt=e=>e.tagName==="INPUT",ot=e=>e.tagName==="STYLE",at=e=>e.tagName==="SCRIPT",st=e=>e.tagName==="SELECT",it=e=>e.tagName==="SLOT",lt=e=>e.tagName==="IFRAME",ct=(...e)=>console.warn(se,...e);function ut(e){var r;const t=(r=e==null?void 0:e.createElement)==null?void 0:r.call(e,"canvas");return t&&(t.height=t.width=1),!!t&&"toDataURL"in t&&!!t.toDataURL("image/webp").includes("image/webp")}const re=e=>e.startsWith("data:");function ue(e,t){if(e.match(/^[a-z]+:\/\//i))return e;if(U&&e.match(/^\/\//))return window.location.protocol+e;if(e.match(/^[a-z]+:/i)||!U)return e;const r=J().implementation.createHTMLDocument(),n=r.createElement("base"),i=r.createElement("a");return r.head.appendChild(n),r.body.appendChild(i),t&&(n.href=t),i.href=e,i.href}function J(e){var t;return(t=e&&I(e)?e==null?void 0:e.ownerDocument:e)!=null?t:window.document}const K="http://www.w3.org/2000/svg";function fe(e,t,r){const n=J(r).createElementNS(K,"svg");return n.setAttributeNS(null,"width",e.toString()),n.setAttributeNS(null,"height",t.toString()),n.setAttributeNS(null,"viewBox",`0 0 ${e} ${t}`),n}function de(e,t){let r=new XMLSerializer().serializeToString(e);return t&&(r=r.replace(/[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu,"")),`data:image/svg+xml;charset=utf-8,${encodeURIComponent(r)}`}function ft(e,t="image/png",r=1){return C(this,null,function*(){try{return yield new Promise((n,i)=>{e.toBlob(a=>{a?n(a):i(new Error("Blob is null"))},t,r)})}catch(n){if(ie)return dt(e.toDataURL(t,r));throw n}})}function dt(e){var o,c;const[t,r]=e.split(","),n=(c=(o=t.match(/data:(.+);/))==null?void 0:o[1])!=null?c:void 0,i=window.atob(r),a=i.length,s=new Uint8Array(a);for(let u=0;u{const i=new FileReader;i.onload=()=>r(i.result),i.onerror=()=>n(i.error),i.onabort=()=>n(new Error(`Failed read blob to ${t}`)),t==="dataUrl"?i.readAsDataURL(e):t==="arrayBuffer"&&i.readAsArrayBuffer(e)})}const gt=e=>ge(e,"dataUrl"),mt=e=>ge(e,"arrayBuffer");function _(e,t){const r=J(t).createElement("img");return r.decoding="sync",r.loading="eager",r.src=e,r}function L(e,t){return new Promise(r=>{const{timeout:n,ownerDocument:i,onError:a,onWarn:s}=t!=null?t:{},o=typeof e=="string"?_(e,J(i)):e;let c=null,u=null;function l(){r(o),c&&clearTimeout(c),u==null||u()}if(n&&(c=setTimeout(l,n)),Y(o)){const d=o.currentSrc||o.src;if(!d)return o.poster?L(o.poster,t).then(r):l();if(o.readyState>=2)return l();const m=l,f=h=>{s==null||s("Failed video load",d,h),a==null||a(h),l()};u=()=>{o.removeEventListener("loadeddata",m),o.removeEventListener("error",f)},o.addEventListener("loadeddata",m,{once:!0}),o.addEventListener("error",f,{once:!0})}else{const d=ce(o)?o.href.baseVal:o.currentSrc||o.src;if(!d)return l();const m=()=>C(this,null,function*(){if($(o)&&"decode"in o)try{yield o.decode()}catch(h){s==null||s("Failed to decode image, trying to render anyway",o.dataset.originalSrc||d,h)}l()}),f=h=>{s==null||s("Failed image load",o.dataset.originalSrc||d,h),l()};if($(o)&&o.complete)return m();u=()=>{o.removeEventListener("load",m),o.removeEventListener("error",f)},o.addEventListener("load",m,{once:!0}),o.addEventListener("error",f,{once:!0})}})}function me(e,t){return C(this,null,function*(){V(e)&&($(e)||Y(e)?yield L(e,t):yield Promise.all(["img","video"].flatMap(r=>Array.from(e.querySelectorAll(r)).map(n=>L(n,t)))))})}const he=function(){let t=0;const r=()=>`0000${(Math.random()*Oe(36,4)<<0).toString(36)}`.slice(-4);return()=>(t+=1,`u${r()}${t}`)}();function we(e){return e==null?void 0:e.split(",").map(t=>t.trim().replace(/"|'/g,"").toLowerCase()).filter(Boolean)}let pe=0;function ht(e){const t=`${se}[#${pe}]`;return pe++,{time:r=>e&&console.time(`${t} ${r}`),timeEnd:r=>e&&console.timeEnd(`${t} ${r}`),warn:(...r)=>e&&ct(...r)}}function wt(e){return{cache:e?"no-cache":"force-cache"}}function k(e,t){return C(this,null,function*(){return Ge(e)?e:ye(e,M(D({},t),{autoDestruct:!0}))})}function ye(e,t){return C(this,null,function*(){var f,h,g,p,E;const{scale:r=1,workerUrl:n,workerNumber:i=1}=t||{},a=!!(t!=null&&t.debug),s=(f=t==null?void 0:t.features)!=null?f:!0,o=(h=e.ownerDocument)!=null?h:U?window.document:void 0,c=(p=(g=e.ownerDocument)==null?void 0:g.defaultView)!=null?p:U?window:void 0,u=new Map,l=M(D({width:0,height:0,quality:1,type:"image/png",scale:r,backgroundColor:null,style:null,filter:null,maximumCanvasSize:0,timeout:3e4,progress:null,debug:a,fetch:D({requestInit:wt((E=t==null?void 0:t.fetch)==null?void 0:E.bypassingCache),placeholderImage:"data:image/png;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",bypassingCache:!1},t==null?void 0:t.fetch),fetchFn:null,font:{},drawImageInterval:100,workerUrl:null,workerNumber:i,onCloneEachNode:null,onCloneNode:null,onEmbedNode:null,onCreateForeignObjectSvg:null,includeStyleProperties:null,autoDestruct:!1},t),{__CONTEXT__:!0,log:ht(a),node:e,ownerDocument:o,ownerWindow:c,dpi:r===1?null:96*r,svgStyleElement:be(o),svgDefsElement:o==null?void 0:o.createElementNS(K,"defs"),svgStyles:new Map,defaultComputedStyles:new Map,workers:[...Array.from({length:ze&&n&&i?i:0})].map(()=>{try{const b=new Worker(n);return b.onmessage=w=>C(this,null,function*(){var A,F,B,$e;const{url:S,result:T}=w.data;T?(F=(A=u.get(S))==null?void 0:A.resolve)==null||F.call(A,T):($e=(B=u.get(S))==null?void 0:B.reject)==null||$e.call(B,new Error(`Error receiving message from worker: ${S}`))}),b.onmessageerror=w=>{var T,A;const{url:S}=w.data;(A=(T=u.get(S))==null?void 0:T.reject)==null||A.call(T,new Error(`Error receiving message from worker: ${S}`))},b}catch(b){return l.log.warn("Failed to new Worker",b),null}}).filter(Boolean),fontFamilies:new Map,fontCssTexts:new Map,acceptOfImage:`${[ut(o)&&"image/webp","image/svg+xml","image/*","*/*"].filter(Boolean).join(",")};q=0.8`,requests:u,drawImageCount:0,tasks:[],features:s,isEnable:b=>{var w,S;return b==="restoreScrollPosition"?typeof s=="boolean"?!1:(w=s[b])!=null?w:!1:typeof s=="boolean"?s:(S=s[b])!=null?S:!0},shadowRoots:[]});l.log.time("wait until load"),yield me(e,{timeout:l.timeout,onWarn:l.log.warn}),l.log.timeEnd("wait until load");const{width:d,height:m}=pt(e,l);return l.width=d,l.height=m,l})}function be(e){if(!e)return;const t=e.createElement("style"),r=t.ownerDocument.createTextNode(` +.______background-clip--text { + background-clip: text; + -webkit-background-clip: text; +} +`);return t.appendChild(r),t}function pt(e,t){let{width:r,height:n}=t;if(I(e)&&(!r||!n)){const i=e.getBoundingClientRect();r=r||i.width||Number(e.getAttribute("width"))||0,n=n||i.height||Number(e.getAttribute("height"))||0}return{width:r,height:n}}function yt(e,t){return C(this,null,function*(){const{log:r,timeout:n,drawImageCount:i,drawImageInterval:a}=t;r.time("image to canvas");const s=yield L(e,{timeout:n,onWarn:t.log.warn}),{canvas:o,context2d:c}=bt(e.ownerDocument,t),u=()=>{try{c==null||c.drawImage(s,0,0,o.width,o.height)}catch(l){t.log.warn("Failed to drawImage",l)}};if(u(),t.isEnable("fixSvgXmlDecode"))for(let l=0;l{setTimeout(()=>{c==null||c.clearRect(0,0,o.width,o.height),u(),d()},l+a)});return t.drawImageCount=0,r.timeEnd("image to canvas"),o})}function bt(e,t){const{width:r,height:n,scale:i,backgroundColor:a,maximumCanvasSize:s}=t,o=e.createElement("canvas");o.width=Math.floor(r*i),o.height=Math.floor(n*i),o.style.width=`${r}px`,o.style.height=`${n}px`,s&&(o.width>s||o.height>s)&&(o.width>s&&o.height>s?o.width>o.height?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s):o.width>s?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s));const c=o.getContext("2d");return c&&a&&(c.fillStyle=a,c.fillRect(0,0,o.width,o.height)),{canvas:o,context2d:c}}function Se(e,t){if(e.ownerDocument)try{const a=e.toDataURL();if(a!=="data:,")return _(a,e.ownerDocument)}catch(a){t.log.warn("Failed to clone canvas",a)}const r=e.cloneNode(!1),n=e.getContext("2d"),i=r.getContext("2d");try{return n&&i&&i.putImageData(n.getImageData(0,0,e.width,e.height),0,0),r}catch(a){t.log.warn("Failed to clone canvas",a)}return r}function St(e,t){var r;try{if((r=e==null?void 0:e.contentDocument)!=null&&r.documentElement)return ne(e.contentDocument.documentElement,t)}catch(n){t.log.warn("Failed to clone iframe",n)}return e.cloneNode(!1)}function Et(e){const t=e.cloneNode(!1);return e.currentSrc&&e.currentSrc!==e.src&&(t.src=e.currentSrc,t.srcset=""),t.loading==="lazy"&&(t.loading="eager"),t}function Ct(e,t){return C(this,null,function*(){if(e.ownerDocument&&!e.currentSrc&&e.poster)return _(e.poster,e.ownerDocument);const r=e.cloneNode(!1);r.crossOrigin="anonymous",e.currentSrc&&e.currentSrc!==e.src&&(r.src=e.currentSrc);const n=r.ownerDocument;if(n){let i=!0;if(yield L(r,{onError:()=>i=!1,onWarn:t.log.warn}),!i)return e.poster?_(e.poster,e.ownerDocument):r;r.currentTime=e.currentTime,yield new Promise(s=>{r.addEventListener("seeked",s,{once:!0})});const a=n.createElement("canvas");a.width=e.offsetWidth,a.height=e.offsetHeight;try{const s=a.getContext("2d");s&&s.drawImage(r,0,0,a.width,a.height)}catch(s){return t.log.warn("Failed to clone video",s),e.poster?_(e.poster,e.ownerDocument):r}return Se(a,t)}return r})}function Tt(e,t){return tt(e)?Se(e,t):lt(e)?St(e,t):$(e)?Et(e):Y(e)?Ct(e,t):e.cloneNode(!1)}function vt(e){let t=e.sandbox;if(!t){const{ownerDocument:r}=e;try{r&&(t=r.createElement("iframe"),t.id=`__SANDBOX__${he()}`,t.width="0",t.height="0",t.style.visibility="hidden",t.style.position="fixed",r.body.appendChild(t),t.srcdoc='',e.sandbox=t)}catch(n){e.log.warn("Failed to getSandBox",n)}}return t}const At=["width","height","-webkit-text-fill-color"],Nt=["stroke","fill"];function Ee(e,t,r){const{defaultComputedStyles:n}=r,i=e.nodeName.toLowerCase(),a=H(e)&&i!=="svg",s=a?Nt.map(g=>[g,e.getAttribute(g)]).filter(([,g])=>g!==null):[],o=[a&&"svg",i,s.map((g,p)=>`${g}=${p}`).join(","),t].filter(Boolean).join(":");if(n.has(o))return n.get(o);const c=vt(r),u=c==null?void 0:c.contentWindow;if(!u)return new Map;const l=u==null?void 0:u.document;let d,m;a?(d=l.createElementNS(K,"svg"),m=d.ownerDocument.createElementNS(d.namespaceURI,i),s.forEach(([g,p])=>{m.setAttributeNS(null,g,p)}),d.appendChild(m)):d=m=l.createElement(i),m.textContent=" ",l.body.appendChild(d);const f=u.getComputedStyle(m,t),h=new Map;for(let g=f.length,p=0;pn.set(d,l));function s(c){const u=e.getPropertyValue(c),l=e.getPropertyPriority(c),d=c.lastIndexOf("-"),m=d>-1?c.substring(0,d):void 0;if(m){let f=a.get(m);f||(f=new Map,a.set(m,f)),f.set(c,[u,l])}t.get(c)===u&&!l||(m?i.push(m):n.set(c,[u,l]))}return n}function Rt(e,t,r,n){var d,m,f,h;const{ownerWindow:i,includeStyleProperties:a,currentParentNodeStyle:s}=n,o=t.style,c=i.getComputedStyle(e),u=Ee(e,null,n);s==null||s.forEach((g,p)=>{u.delete(p)});const l=Ce(c,u,a);l.delete("transition-property"),l.delete("all"),l.delete("d"),l.delete("content"),r&&(l.delete("position"),l.delete("margin-top"),l.delete("margin-right"),l.delete("margin-bottom"),l.delete("margin-left"),l.delete("margin-block-start"),l.delete("margin-block-end"),l.delete("margin-inline-start"),l.delete("margin-inline-end"),l.set("box-sizing",["border-box",""])),((d=l.get("background-clip"))==null?void 0:d[0])==="text"&&t.classList.add("______background-clip--text"),le&&(l.has("font-kerning")||l.set("font-kerning",["normal",""]),(((m=l.get("overflow-x"))==null?void 0:m[0])==="hidden"||((f=l.get("overflow-y"))==null?void 0:f[0])==="hidden")&&((h=l.get("text-overflow"))==null?void 0:h[0])==="ellipsis"&&e.scrollWidth===e.clientWidth&&l.set("text-overflow",["clip",""]));for(let g=o.length,p=0;p{o.setProperty(E,g,p)}),l}function It(e,t){(rt(e)||nt(e)||st(e))&&t.setAttribute("value",e.value)}const kt=["::before","::after"],Dt=["::-webkit-scrollbar","::-webkit-scrollbar-button","::-webkit-scrollbar-thumb","::-webkit-scrollbar-track","::-webkit-scrollbar-track-piece","::-webkit-scrollbar-corner","::-webkit-resizer"];function Pt(e,t,r,n,i){const{ownerWindow:a,svgStyleElement:s,svgStyles:o,currentNodeStyle:c}=n;if(!s||!a)return;function u(l){var w;const d=a.getComputedStyle(e,l);let m=d.getPropertyValue("content");if(!m||m==="none")return;i==null||i(m),m=m.replace(/(')|(")|(counter\(.+\))/g,"");const f=[he()],h=Ee(e,l,n);c==null||c.forEach((S,T)=>{h.delete(T)});const g=Ce(d,h,n.includeStyleProperties);g.delete("content"),g.delete("-webkit-locale"),((w=g.get("background-clip"))==null?void 0:w[0])==="text"&&t.classList.add("______background-clip--text");const p=[`content: '${m}';`];if(g.forEach(([S,T],A)=>{p.push(`${A}: ${S}${T?" !important":""};`)}),p.length===1)return;try{t.className=[t.className,...f].join(" ")}catch(S){n.log.warn("Failed to copyPseudoClass",S);return}const E=p.join(` + `);let b=o.get(E);b||(b=[],o.set(E,b)),b.push(`.${f[0]}${l}`)}kt.forEach(u),r&&Dt.forEach(u)}const Te=new Set(["symbol"]);function ve(e,t,r,n,i){return C(this,null,function*(){if(I(r)&&(ot(r)||at(r))||n.filter&&!n.filter(r))return;Te.has(t.nodeName)||Te.has(r.nodeName)?n.currentParentNodeStyle=void 0:n.currentParentNodeStyle=n.currentNodeStyle;const a=yield ne(r,n,!1,i);n.isEnable("restoreScrollPosition")&&Ut(e,a),t.appendChild(a)})}function Ae(e,t,r,n){return C(this,null,function*(){var a;let i=e.firstChild;I(e)&&e.shadowRoot&&(i=(a=e.shadowRoot)==null?void 0:a.firstChild,r.shadowRoots.push(e.shadowRoot));for(let s=i;s;s=s.nextSibling)if(!Ze(s))if(I(s)&&it(s)&&typeof s.assignedNodes=="function"){const o=s.assignedNodes();for(let c=0;ce.clientHeight||e.scrollWidth>e.clientWidth)}const p=(d=h.get("text-transform"))==null?void 0:d[0],E=we((m=h.get("font-family"))==null?void 0:m[0]),b=E?w=>{p==="uppercase"?w=w.toUpperCase():p==="lowercase"?w=w.toLowerCase():p==="capitalize"&&(w=w[0].toUpperCase()+w.substring(1)),E.forEach(S=>{let T=s.get(S);T||s.set(S,T=new Set),w.split("").forEach(A=>T.add(A))})}:void 0;return Pt(e,f,g,t,b),It(e,f),Y(e)||(yield Ae(e,f,t,b)),yield o==null?void 0:o(f),f}const c=e.cloneNode(!1);return yield Ae(e,c,t),yield o==null?void 0:o(c),c})}function Ne(e){if(e.ownerDocument=void 0,e.ownerWindow=void 0,e.svgStyleElement=void 0,e.svgDefsElement=void 0,e.svgStyles.clear(),e.defaultComputedStyles.clear(),e.sandbox){try{e.sandbox.remove()}catch(t){e.log.warn("Failed to destroyContext",t)}e.sandbox=void 0}e.workers=[],e.fontFamilies.clear(),e.fontCssTexts.clear(),e.requests.clear(),e.tasks=[],e.shadowRoots=[]}function Bt(e){const o=e,{url:t,timeout:r,responseType:n}=o,i=je(o,["url","timeout","responseType"]),a=new AbortController,s=r?setTimeout(()=>a.abort(),r):void 0;return fetch(t,D({signal:a.signal},i)).then(c=>{if(!c.ok)throw new Error("Failed fetch, not 2xx response",{cause:c});switch(n){case"arrayBuffer":return c.arrayBuffer();case"dataUrl":return c.blob().then(gt);case"text":default:return c.text()}}).finally(()=>clearTimeout(s))}function z(e,t){const{url:r,requestType:n="text",responseType:i="text",imageDom:a}=t;let s=r;const{timeout:o,acceptOfImage:c,requests:u,fetchFn:l,fetch:{requestInit:d,bypassingCache:m,placeholderImage:f},font:h,workers:g,fontFamilies:p}=e;n==="image"&&(G||te)&&e.drawImageCount++;let E=u.get(r);if(!E){m&&m instanceof RegExp&&m.test(s)&&(s+=(/\?/.test(s)?"&":"?")+new Date().getTime());const b=n.startsWith("font")&&h&&h.minify,w=new Set;b&&n.split(";")[1].split(",").forEach(F=>{p.has(F)&&p.get(F).forEach(B=>w.add(B))});const S=b&&w.size,T=D({url:s,timeout:o,responseType:S?"arrayBuffer":i,headers:n==="image"?{accept:c}:void 0},d);E={type:n,resolve:void 0,reject:void 0,response:null},E.response=C(this,null,function*(){if(l&&n==="image"){const A=yield l(r);if(A)return A}return!G&&r.startsWith("http")&&g.length?new Promise((A,F)=>{g[u.size&g.length-1].postMessage(D({rawUrl:r},T)),E.resolve=A,E.reject=F}):Bt(T)}).catch(A=>{if(u.delete(r),n==="image"&&f)return e.log.warn("Failed to fetch image base64, trying to use placeholder image",s),typeof f=="string"?f:f(a);throw A}),u.set(r,E)}return E.response}function Re(e,t,r,n){return C(this,null,function*(){if(!Ie(e))return e;for(const[i,a]of $t(e,t))try{const s=yield z(r,{url:a,requestType:n?"image":"text",responseType:"dataUrl"});e=e.replace(Lt(i),`$1${s}$3`)}catch(s){r.log.warn("Failed to fetch css data url",i,s)}return e})}function Ie(e){return/url\((['"]?)([^'"]+?)\1\)/.test(e)}const ke=/url\((['"]?)([^'"]+?)\1\)/g;function $t(e,t){const r=[];return e.replace(ke,(n,i,a)=>(r.push([a,ue(a,t)]),n)),r.filter(([n])=>!re(n))}function Lt(e){const t=e.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${t})(['"]?\\))`,"g")}const xt=["background-image","border-image-source","-webkit-border-image","-webkit-mask-image","list-style-image"];function Mt(e,t){return xt.map(r=>{const n=e.getPropertyValue(r);return!n||n==="none"?null:((G||te)&&t.drawImageCount++,Re(n,null,t,!0).then(i=>{!i||n===i||e.setProperty(r,i,e.getPropertyPriority(r))}))}).filter(Boolean)}function Ot(e,t){if($(e)){const r=e.currentSrc||e.src;if(!re(r))return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.srcset="",e.dataset.originalSrc=r,e.src=n||"")})];(G||te)&&t.drawImageCount++}else if(H(e)&&!re(e.href.baseVal)){const r=e.href.baseVal;return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.dataset.originalSrc=r,e.href.baseVal=n||"")})]}return[]}function jt(e,t){var o;const{ownerDocument:r,svgDefsElement:n}=t,i=(o=e.getAttribute("href"))!=null?o:e.getAttribute("xlink:href");if(!i)return[];const[a,s]=i.split("#");if(s){const c=`#${s}`,u=t.shadowRoots.reduce((l,d)=>l!=null?l:d.querySelector(`svg ${c}`),r==null?void 0:r.querySelector(`svg ${c}`));if(a&&e.setAttribute("href",c),n!=null&&n.querySelector(c))return[];if(u)return n==null||n.appendChild(u.cloneNode(!0)),[];if(a)return[z(t,{url:a,responseType:"text"}).then(l=>{n==null||n.insertAdjacentHTML("beforeend",l)})]}return[]}function De(e,t){const{tasks:r}=t;I(e)&&(($(e)||ce(e))&&r.push(...Ot(e,t)),Qe(e)&&r.push(...jt(e,t))),V(e)&&r.push(...Mt(e.style,t)),e.childNodes.forEach(n=>{De(n,t)})}function qt(e,t){return C(this,null,function*(){const{ownerDocument:r,svgStyleElement:n,fontFamilies:i,fontCssTexts:a,tasks:s,font:o}=t;if(!(!r||!n||!i.size))if(o&&o.cssText){const c=Ue(o.cssText,t);n.appendChild(r.createTextNode(`${c} +`))}else{const c=Array.from(r.styleSheets).filter(f=>{try{return"cssRules"in f&&!!f.cssRules.length}catch(h){return t.log.warn(`Error while reading CSS rules from ${f.href}`,h),!1}}),u=r.implementation.createHTMLDocument(""),l=u.createElement("style");u.head.appendChild(l);const d=l.sheet;yield Promise.all(c.flatMap(f=>Array.from(f.cssRules).map(h=>C(this,null,function*(){if(Je(h)){const g=h.href;let p="";try{p=yield z(t,{url:g,requestType:"text",responseType:"text"})}catch(b){t.log.warn(`Error fetch remote css import from ${g}`,b)}const E=p.replace(ke,(b,w,S)=>b.replace(S,ue(S,g)));for(const b of Ht(E))try{d.insertRule(b,d.cssRules.length)}catch(w){t.log.warn("Error inserting rule from remote css import",{rule:b,error:w})}}})))),d.cssRules.length&&c.push(d);const m=[];c.forEach(f=>{oe(f.cssRules,m)}),m.filter(f=>{var h;return Ye(f)&&Ie(f.style.getPropertyValue("src"))&&((h=we(f.style.getPropertyValue("font-family")))==null?void 0:h.some(g=>i.has(g)))}).forEach(f=>{const h=f,g=a.get(h.cssText);g?n.appendChild(r.createTextNode(`${g} +`)):s.push(Re(h.cssText,h.parentStyleSheet?h.parentStyleSheet.href:null,t).then(p=>{p=Ue(p,t),a.set(h.cssText,p),n.appendChild(r.createTextNode(`${p} +`))}))})}})}const Wt=/(\/\*[\s\S]*?\*\/)/g,Pe=/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi;function Ht(e){if(e==null)return[];const t=[];let r=e.replace(Wt,"");for(;;){const a=Pe.exec(r);if(!a)break;t.push(a[0])}r=r.replace(Pe,"");const n=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,i=new RegExp("((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})","gi");for(;;){let a=n.exec(r);if(a)i.lastIndex=n.lastIndex;else if(a=i.exec(r),a)n.lastIndex=i.lastIndex;else break;t.push(a[0])}return t}const Vt=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,zt=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Ue(e,t){const{font:r}=t,n=r?r==null?void 0:r.preferredFormat:void 0;return n?e.replace(zt,i=>{for(;;){const[a,,s]=Vt.exec(i)||[];if(!s)return"";if(s===n)return`src: ${a};`}}):e}function oe(e,t=[]){for(const r of Array.from(e))Ke(r)?t.push(...oe(r.cssRules)):"cssRules"in r?oe(r.cssRules,t):t.push(r);return t}const Xt=/\bx?link:?href\s*=\s*["'](?!data:)[^"']+["']/i;function Gt(e){return Xt.test(e.innerHTML)}function _e(e,t){return C(this,null,function*(){const r=yield k(e,t);if(I(r.node)&&H(r.node)&&!Gt(r.node))return r.node;const{ownerDocument:n,log:i,tasks:a,svgStyleElement:s,svgDefsElement:o,svgStyles:c,font:u,progress:l,autoDestruct:d,onCloneNode:m,onEmbedNode:f,onCreateForeignObjectSvg:h}=r;i.time("clone node");const g=yield ne(r.node,r,!0);if(s&&n){let S="";c.forEach((T,A)=>{S+=`${T.join(`, +`)} { + ${A} +} +`}),s.appendChild(n.createTextNode(S))}i.timeEnd("clone node"),yield m==null?void 0:m(g),u!==!1&&I(g)&&(i.time("embed web font"),yield qt(g,r),i.timeEnd("embed web font")),i.time("embed node"),De(g,r);const p=a.length;let E=0;const b=()=>C(this,null,function*(){for(;;){const S=a.pop();if(!S)break;try{yield S}catch(T){r.log.warn("Failed to run task",T)}l==null||l(++E,p)}});l==null||l(E,p),yield Promise.all([...Array.from({length:4})].map(b)),i.timeEnd("embed node"),yield f==null?void 0:f(g);const w=Yt(g,r);return o&&w.insertBefore(o,w.children[0]),s&&w.insertBefore(s,w.children[0]),d&&Ne(r),yield h==null?void 0:h(w),w})}function Yt(e,t){const{width:r,height:n}=t,i=fe(r,n,e.ownerDocument),a=i.ownerDocument.createElementNS(i.namespaceURI,"foreignObject");return a.setAttributeNS(null,"x","0%"),a.setAttributeNS(null,"y","0%"),a.setAttributeNS(null,"width","100%"),a.setAttributeNS(null,"height","100%"),a.append(e),i.appendChild(a),i}function Q(e,t){return C(this,null,function*(){var s;const r=yield k(e,t),n=yield _e(r),i=de(n,r.isEnable("removeControlCharacter"));r.autoDestruct||(r.svgStyleElement=be(r.ownerDocument),r.svgDefsElement=(s=r.ownerDocument)==null?void 0:s.createElementNS(K,"defs"),r.svgStyles.clear());const a=_(i,n.ownerDocument);return yield yt(a,r)})}function Jt(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,type:i,quality:a,dpi:s}=r,o=yield Q(r);n.time("canvas to blob");const c=yield ft(o,i,a);if(["image/png","image/jpeg"].includes(i)&&s){const u=yield mt(c.slice(0,33));let l=new Uint8Array(u);return i==="image/png"?l=ae(l,s):i==="image/jpeg"&&(l=v(l,s)),n.timeEnd("canvas to blob"),new Blob([l,c.slice(33)],{type:i})}return n.timeEnd("canvas to blob"),c})}function x(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,quality:i,type:a,dpi:s}=r,o=yield Q(r);n.time("canvas to data url");let c=o.toDataURL(a,i);if(["image/png","image/jpeg"].includes(a)&&s&&ie&&Xe){const[u,l]=c.split(",");let d=0,m=!1;if(a==="image/png"){const w=Ve(l);w>=0?(d=Math.ceil((w+28)/3)*4,m=!0):d=33/3*4}else a==="image/jpeg"&&(d=18/3*4);const f=l.substring(0,d),h=l.substring(d),g=window.atob(f),p=new Uint8Array(g.length);for(let w=0;w mirroring selectedElement's bounding rect. Click (no + // drag) drops a comment pin; drag paints a magenta SVG stroke. All coords + // are stored in element-local CSS px so they survive scroll / resize and + // correlate directly with the captured PNG. + // --------------------------------------------------------------------------- + + const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click + const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it + let annotOverlayEl = null; + let annotSvgEl = null; + let annotPinsEl = null; + let annotClearChipEl = null; + let annotState = { comments: [], strokes: [] }; + let annotActive = false; + // `annotPointer` is either: + // { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin + // { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin + let annotPointer = null; + let annotEditing = null; // { idx, input, wrapEl } + let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete + + function initAnnotOverlay() { + annotOverlayEl = document.createElement('div'); + annotOverlayEl.id = PREFIX + '-annot'; + Object.assign(annotOverlayEl.style, { + position: 'fixed', top: '0', left: '0', width: '0', height: '0', + pointerEvents: 'auto', zIndex: Z.highlight + 2, + display: 'none', overflow: 'visible', + cursor: 'crosshair', touchAction: 'none', + }); + + annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + annotSvgEl.id = PREFIX + '-annot-svg'; + Object.assign(annotSvgEl.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', + // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via + // pointer-events=stroke so gaps still fall through to the overlay. + pointerEvents: 'none', overflow: 'visible', + }); + annotOverlayEl.appendChild(annotSvgEl); + + annotPinsEl = document.createElement('div'); + annotPinsEl.id = PREFIX + '-annot-pins'; + Object.assign(annotPinsEl.style, { + position: 'absolute', inset: '0', + pointerEvents: 'none', + }); + annotOverlayEl.appendChild(annotPinsEl); + + annotClearChipEl = document.createElement('div'); + annotClearChipEl.id = PREFIX + '-annot-clear'; + annotClearChipEl.dataset.annotClear = 'true'; + annotClearChipEl.textContent = 'Clear'; + Object.assign(annotClearChipEl.style, { + position: 'absolute', top: '8px', right: '8px', + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '10px', fontWeight: '500', + letterSpacing: '0.08em', textTransform: 'uppercase', + padding: '5px 12px', borderRadius: '999px', + cursor: 'pointer', pointerEvents: 'auto', + display: 'none', userSelect: 'none', + boxShadow: '0 1px 3px rgba(0,0,0,0.2)', + }); + annotOverlayEl.appendChild(annotClearChipEl); + + annotOverlayEl.addEventListener('pointerdown', onAnnotDown); + annotOverlayEl.addEventListener('pointermove', onAnnotMove); + annotOverlayEl.addEventListener('pointerup', onAnnotUp); + annotOverlayEl.addEventListener('pointercancel', onAnnotUp); + document.body.appendChild(annotOverlayEl); + } + + function updateClearChip() { + if (!annotClearChipEl) return; + const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0; + annotClearChipEl.style.display = hasAny ? 'block' : 'none'; + } + + function showAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + annotActive = true; + positionAnnotOverlay(el); + annotOverlayEl.style.display = 'block'; + } + + function hideAnnotOverlay() { + annotActive = false; + if (annotOverlayEl) annotOverlayEl.style.display = 'none'; + // Drop any in-progress edit without touching annotState — clearAnnotations + // (if the caller is exiting configure mode) handles state reset. + annotEditing = null; + } + + function positionAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + const r = el.getBoundingClientRect(); + Object.assign(annotOverlayEl.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height); + } + + function clearAnnotations() { + annotState.comments = []; + annotState.strokes = []; + if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + if (annotPinsEl) annotPinsEl.innerHTML = ''; + annotPointer = null; + annotEditing = null; + annotLastPinClick = { idx: -1, time: 0 }; + updateClearChip(); + } + + // Rebuild the SVG layer. Each stroke gets a wider invisible hit path + // beneath the visible magenta path so clicks register on thin lines. + function redrawStrokes() { + while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + annotState.strokes.forEach((s, idx) => { + const d = pointsToPath(s.points); + const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + hit.setAttribute('d', d); + hit.setAttribute('stroke', 'transparent'); + hit.setAttribute('stroke-width', '16'); + hit.setAttribute('stroke-linecap', 'round'); + hit.setAttribute('stroke-linejoin', 'round'); + hit.setAttribute('fill', 'none'); + hit.setAttribute('pointer-events', 'stroke'); + hit.style.cursor = 'pointer'; + hit.dataset.annotStroke = String(idx); + annotSvgEl.appendChild(hit); + const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + visible.setAttribute('d', d); + visible.setAttribute('stroke', C.brand); + visible.setAttribute('stroke-width', '3'); + visible.setAttribute('stroke-linecap', 'round'); + visible.setAttribute('stroke-linejoin', 'round'); + visible.setAttribute('fill', 'none'); + visible.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(visible); + }); + updateClearChip(); + } + + function localCoords(e) { + const rect = annotOverlayEl.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + function onAnnotDown(e) { + if (!annotActive) return; + + // 1) Clear chip → wipe all annotations + if (e.target.closest?.('[data-annot-clear]')) { + if (annotEditing) annotEditing = null; + clearAnnotations(); + renderAllPins(); + redrawStrokes(); + e.stopPropagation(); e.preventDefault(); + return; + } + + // 2) Stroke hit path → delete that stroke + const strokeHit = e.target.closest?.('[data-annot-stroke]'); + if (strokeHit) { + const idx = parseInt(strokeHit.dataset.annotStroke, 10); + if (Number.isInteger(idx)) { + annotState.strokes.splice(idx, 1); + redrawStrokes(); + } + e.stopPropagation(); e.preventDefault(); + return; + } + + // 3) Pin → drag, edit, or delete-on-double-click + const pinWrap = e.target.closest?.('[data-annot-pin]'); + if (pinWrap) { + const idx = parseInt(pinWrap.dataset.annotPin, 10); + if (!Number.isInteger(idx)) return; + // Double-click (two pointerdowns on the same pin within window) → delete. + const now = Date.now(); + if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) { + if (annotEditing && annotEditing.idx === idx) annotEditing = null; + annotState.comments.splice(idx, 1); + annotLastPinClick = { idx: -1, time: 0 }; + renderAllPins(); + e.stopPropagation(); e.preventDefault(); + return; + } + annotLastPinClick = { idx, time: now }; + // If editing a different pin, commit that edit before starting here. + if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin(); + // If already editing THIS pin and the user clicked the dot, let the + // input keep focus (don't start a drag — the click wasn't meant as one). + if (annotEditing && annotEditing.idx === idx) return; + const p = localCoords(e); + const pin = annotState.comments[idx]; + annotPointer = { + kind: 'pin', idx, + startPointer: p, + startPin: { x: pin.x, y: pin.y }, + moved: false, + }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + return; + } + + // 4) Empty area → commit any open edit, then start new annotation + if (annotEditing) { + finalizeEditingPin(); + e.stopPropagation(); e.preventDefault(); + return; + } + const p = localCoords(e); + annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + } + + function onAnnotMove(e) { + if (!annotActive || !annotPointer) return; + const p = localCoords(e); + + if (annotPointer.kind === 'pin') { + const dx = p.x - annotPointer.startPointer.x; + const dy = p.y - annotPointer.startPointer.y; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + } + const pin = annotState.comments[annotPointer.idx]; + if (!pin) { annotPointer = null; return; } + pin.x = annotPointer.startPin.x + dx; + pin.y = annotPointer.startPin.y + dy; + renderAllPins(); + e.stopPropagation(); + return; + } + + // kind === 'new' + const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + strokeEl.setAttribute('stroke', C.brand); + strokeEl.setAttribute('stroke-width', '3'); + strokeEl.setAttribute('stroke-linecap', 'round'); + strokeEl.setAttribute('stroke-linejoin', 'round'); + strokeEl.setAttribute('fill', 'none'); + strokeEl.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(strokeEl); + annotPointer.strokeEl = strokeEl; + annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]]; + } + annotPointer.strokePoints.push([p.x, p.y]); + annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints)); + e.stopPropagation(); + } + + function onAnnotUp(e) { + if (!annotActive || !annotPointer) return; + + if (annotPointer.kind === 'pin') { + const wasDrag = annotPointer.moved; + const idx = annotPointer.idx; + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + if (wasDrag) { + // A drag is an intentional reposition; a follow-up click shouldn't be + // interpreted as a double-click-to-delete. + annotLastPinClick = { idx: -1, time: 0 }; + } else { + beginEditPin(idx); + } + e.stopPropagation(); + return; + } + + // kind === 'new' + const wasDrag = annotPointer.moved; + if (wasDrag) { + annotState.strokes.push({ points: annotPointer.strokePoints }); + // Swap the temporary preview SVG path for the full render with hit paths. + redrawStrokes(); + } else { + const idx = annotState.comments.length; + annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' }); + renderAllPins(); + beginEditPin(idx); + } + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + e.stopPropagation(); + } + + function pointsToPath(points) { + if (!points || points.length === 0) return ''; + let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1); + for (let i = 1; i < points.length; i++) { + d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1); + } + return d; + } + + function renderAllPins() { + annotPinsEl.innerHTML = ''; + annotState.comments.forEach((c, idx) => { + annotPinsEl.appendChild(buildPinElement(c, idx)); + }); + updateClearChip(); + } + + function buildPinElement(comment, idx) { + const interactive = idx >= 0; + const wrap = document.createElement('div'); + if (interactive) wrap.dataset.annotPin = String(idx); + Object.assign(wrap.style, { + position: 'absolute', + left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px', + pointerEvents: interactive ? 'auto' : 'none', + display: 'flex', alignItems: 'flex-start', gap: '6px', + cursor: interactive ? 'grab' : 'default', + touchAction: 'none', + }); + const dot = document.createElement('div'); + Object.assign(dot.style, { + width: '14px', height: '14px', borderRadius: '50%', + background: C.brand, border: '2px solid ' + C.white, + boxShadow: '0 1px 3px rgba(0,0,0,0.25)', + flexShrink: '0', + }); + wrap.appendChild(dot); + + if (comment.text) { + const bubble = document.createElement('div'); + bubble.textContent = comment.text; + Object.assign(bubble.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + marginTop: '-2px', maxWidth: '220px', + pointerEvents: 'none', whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }); + wrap.appendChild(bubble); + } + return wrap; + } + + function beginEditPin(idx) { + const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]'); + if (!wrapEl) return; + // Strip any existing bubble (but keep the dot) + wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove()); + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Note…'; + Object.assign(input.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + border: '1px solid ' + C.brand, + outline: 'none', marginTop: '-2px', + width: '220px', pointerEvents: 'auto', + }); + const originalText = annotState.comments[idx].text || ''; + input.value = originalText; + wrapEl.appendChild(input); + annotEditing = { idx, input, wrapEl, originalText }; + input.addEventListener('keydown', onAnnotInputKey, true); + input.addEventListener('blur', () => { + // Fires on both focus-loss and programmatic blur; commit unless we + // already handled it. + if (annotEditing && annotEditing.input === input) finalizeEditingPin(); + }); + // Stop clicks/pointerdowns inside the input from bubbling to the overlay + ['pointerdown', 'click'].forEach(ev => { + input.addEventListener(ev, e => e.stopPropagation()); + }); + setTimeout(() => input.focus(), 0); + } + + function onAnnotInputKey(e) { + if (e.key === 'Enter') { + e.preventDefault(); e.stopPropagation(); + finalizeEditingPin(); + } else if (e.key === 'Escape') { + e.preventDefault(); e.stopPropagation(); + cancelEditingPin(); + } else { + // Keep arrows / backspace from hitting global handlers + e.stopPropagation(); + } + } + + function finalizeEditingPin() { + if (!annotEditing) return; + const { idx, input } = annotEditing; + const text = input.value.trim(); + annotEditing = null; + if (text) annotState.comments[idx].text = text; + else annotState.comments.splice(idx, 1); + renderAllPins(); + } + + function cancelEditingPin() { + if (!annotEditing) return; + const { idx, originalText } = annotEditing; + annotEditing = null; + // If the pin had text before this edit, revert to it. If it was a + // just-created empty pin, Escape removes it. + if (originalText) { + annotState.comments[idx].text = originalText; + } else { + annotState.comments.splice(idx, 1); + } + renderAllPins(); + } + + // Build a detached annotation subtree suitable for injection into the clone + // modern-screenshot creates. Coordinates are element-local so this slots + // straight into an element that's been made position:relative. Takes an + // explicit snapshot so it works after annotState has been cleared. + function buildAnnotationsForCapture(rect, snapshot) { + const comments = snapshot ? snapshot.comments : annotState.comments; + const strokes = snapshot ? snapshot.strokes : annotState.strokes; + if (comments.length === 0 && strokes.length === 0) return null; + const wrap = document.createElement('div'); + Object.assign(wrap.style, { + position: 'absolute', top: '0', left: '0', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', overflow: 'visible', + }); + if (strokes.length > 0) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height); + Object.assign(svg.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', overflow: 'visible', + }); + for (const s of strokes) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('stroke', C.brand); + path.setAttribute('stroke-width', '3'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('fill', 'none'); + path.setAttribute('d', pointsToPath(s.points)); + svg.appendChild(path); + } + wrap.appendChild(svg); + } + for (const c of comments) { + // idx=-1 means non-interactive; pointerEvents stay off in the clone + wrap.appendChild(buildPinElement(c, -1)); + } + return wrap; } // --------------------------------------------------------------------------- @@ -416,8 +901,10 @@ fontSize: '11px', color: C.ash, whiteSpace: 'nowrap', marginLeft: 'auto', }); + // Variants currently arrive atomically in a single file edit, so a + // per-variant counter would lie. Say what's true. status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + (arrivedVariants + 1) + ' of ' + expectedVariants + '...' + ? 'Generating ' + expectedVariants + ' variants...' : 'Done'; row.appendChild(status); @@ -838,6 +1325,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + hideShaderOverlay(); updateBarContent('cycling'); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -860,6 +1348,10 @@ positionBar(); showHighlight(selectedElement); } + if (annotActive) positionAnnotOverlay(selectedElement); + // Shader overlay (via debug P toggle or generation) is repositioned + // by its own branch below; debug no longer has a separate overlay. + if (shaderState) positionShaderOverlay(); scrollRaf = requestAnimationFrame(tick); } scrollRaf = requestAnimationFrame(tick); @@ -937,6 +1429,8 @@ } hideBar(); hideHighlight(); + hideShaderOverlay(); + hideAnnotOverlay(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } clearSession(); @@ -976,6 +1470,8 @@ if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { hideBar(); stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -989,15 +1485,19 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); } function handleKeyDown(e) { + // When the annotation input is focused, let it handle its own keys. + if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); state = 'PICKING'; return; } + if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { hideHighlight(); state = 'IDLE'; return; } @@ -1024,6 +1524,8 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); return; @@ -1035,6 +1537,8 @@ } else { // CONFIGURING: re-select the new element and refresh the bar selectedElement = next; + clearAnnotations(); + showAnnotOverlay(next); showBar('configure'); startScrollTracking(); } @@ -1056,25 +1560,408 @@ const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; + // Commit any pending pin edit BEFORE we snapshot annotations. + if (annotEditing) finalizeEditingPin(); + currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; - sendEvent({ + // Flip to GENERATING immediately so the bar morphs without waiting on + // capture + upload. The event is emitted from captureAndEmit() once the + // screenshot is uploaded (or capture fails — we still emit, just without + // screenshotPath). + const elForCapture = selectedElement; + const captureRect = elForCapture.getBoundingClientRect(); + const snapshot = { + comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), + strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })), + }; + const basePayload = { type: 'generate', id: currentSessionId, action: selectedAction, freeformPrompt: prompt || undefined, count: selectedCount, pageUrl: location.pathname, - element: extractContext(selectedElement), - }); + element: extractContext(elForCapture), + }; + if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments; + if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes; + + // Hide the interactive overlay so it doesn't linger during generation. + hideAnnotOverlay(); + clearAnnotations(); state = 'GENERATING'; showBar('generating'); saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + + captureAndEmit(elForCapture, basePayload, snapshot, captureRect); + } + + // --------------------------------------------------------------------------- + // Screenshot capture + upload + // --------------------------------------------------------------------------- + + let msLoadPromise = null; + function loadModernScreenshot() { + if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot); + if (msLoadPromise) return msLoadPromise; + msLoadPromise = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; + s.onload = () => resolve(window.modernScreenshot); + s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; + document.head.appendChild(s); + }); + return msLoadPromise; + } + + // Collect @font-face rules from every stylesheet on the page. Cross-origin + // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules + // access, so modern-screenshot can't embed them on its own — the resulting + // SVG falls back to system fonts and text re-wraps + renders with different + // weight. We fetch the raw CSS text (CORS-permitted for these providers), + // extract @font-face blocks, inline the referenced font files as base64 + // data URIs (SVGs rasterized via canvas can't fetch external resources, + // so URLs inside the SVG silently fail without this), and pass the result + // to modern-screenshot as font.cssText. + const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i; + const FONT_MIME = { + woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject', + }; + function bufferToBase64(buf) { + const bytes = new Uint8Array(buf); + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); + } + async function inlineFontUrls(cssText) { + const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g; + const urls = new Set(); + let m; + while ((m = urlRe.exec(cssText))) { + if (FONT_EXT_RE.test(m[2])) urls.add(m[2]); + } + const map = new Map(); + await Promise.all([...urls].map(async (url) => { + try { + const res = await fetch(url); + if (!res.ok) return; + const buf = await res.arrayBuffer(); + const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2'; + const mime = FONT_MIME[ext] || 'application/octet-stream'; + map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf)); + } catch { /* skip; fall through to URL */ } + })); + return cssText.replace(urlRe, (orig, q, url) => { + const data = map.get(url); + return data ? 'url(' + q + data + q + ')' : orig; + }); + } + async function collectFontCssText() { + const chunks = []; + const fontFaceRe = /@font-face\s*\{[^}]*\}/g; + for (const sheet of document.styleSheets) { + try { + const rules = sheet.cssRules; + for (const rule of rules) { + if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) { + chunks.push(rule.cssText); + } + } + } catch { + if (!sheet.href) continue; + try { + const res = await fetch(sheet.href); + if (!res.ok) continue; + const text = await res.text(); + let m2; + while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]); + } catch { /* ignore; capture is best-effort */ } + } + } + if (chunks.length === 0) return ''; + return inlineFontUrls(chunks.join('\n')); + } + + // Capture the element (with current annotations baked in) and return a PNG + // Blob. Shared between the Go flow (uploads it to the server) and the + // debug toggle (displays it as an overlay for side-by-side comparison). + async function captureElementToBlob(el, snapshot, rect) { + try { if (document.fonts?.ready) await document.fonts.ready; } catch {} + const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); + let annotNode = null; + let savedPosition = null; + if (hasAnnotations) { + const pos = getComputedStyle(el).position; + if (pos === 'static') { + savedPosition = el.style.position; + el.style.position = 'relative'; + } + annotNode = buildAnnotationsForCapture(rect, snapshot); + el.appendChild(annotNode); + } + try { + const ms = await loadModernScreenshot(); + const fontCssText = await collectFontCssText(); + return await ms.domToBlob(el, { + scale: Math.min(window.devicePixelRatio || 1, 2), + backgroundColor: getComputedStyle(document.body).backgroundColor || '#ffffff', + font: fontCssText ? { cssText: fontCssText } : undefined, + }); + } finally { + if (annotNode) annotNode.remove(); + if (savedPosition !== null) el.style.position = savedPosition; + } + } + + async function captureAndEmit(el, basePayload, snapshot, rect) { + let screenshotPath; + let blob; + try { + blob = await captureElementToBlob(el, snapshot, rect); + } catch (err) { + console.warn('[impeccable] capture failed, proceeding without screenshot:', err); + } + // Light up the shader overlay the moment capture is ready — no reason to + // wait for the upload to complete before the user sees something alive. + if (blob && state === 'GENERATING') { + showShaderOverlay(el, blob, rect); + } + if (blob) { + try { + const uploadRes = await fetch( + 'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) + + '&eventId=' + encodeURIComponent(basePayload.id), + { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob }, + ); + if (uploadRes.ok) { + const { path: p } = await uploadRes.json(); + screenshotPath = p; + } else { + console.warn('[impeccable] annotation upload failed:', uploadRes.status); + } + } catch (err) { + console.warn('[impeccable] annotation upload failed:', err); + } + } + sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); + } + + // --------------------------------------------------------------------------- + // Shader overlay — renders the captured screenshot as a WebGL texture and + // runs an editorial "ink-wash" fragment shader over it during generation. + // A single rolling band sweeps top-to-bottom, desaturating + tinting magenta + // and leaving a soft trail. Makes the wait feel like a letterpress scan + // instead of a dead spinner. + // --------------------------------------------------------------------------- + + const SHADER_VS = `attribute vec2 a_position; +attribute vec2 a_uv; +varying vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_position, 0.0, 1.0); +}`; + + const SHADER_FS = `precision highp float; +uniform sampler2D u_texture; +uniform float u_time; +uniform vec2 u_resolution; +uniform vec3 u_accent; +varying vec2 v_uv; + +// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at +// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean +// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below" +// failure that reversed-edge smoothstep would give). +float bandAt(float d, float leadW, float trailW) { + float above = smoothstep(-leadW, 0.0, d); + float below = 1.0 - smoothstep(0.0, trailW, d); + return above * below; +} + +void main() { + vec2 uv = v_uv; + // Roller sweeps top-to-bottom with small overshoot so each cycle enters + // and exits the element cleanly. + float phase = fract(u_time / 3.4); + float y = phase * 1.25 - 0.12; + float band = bandAt(uv.y - y, 0.05, 0.32); + + // Halftone cell grid (fixed ~10 px pitch). + float cellPx = 10.0; + vec2 gridUv = uv * u_resolution / cellPx; + vec2 cellId = floor(gridUv); + vec2 cellUv = fract(gridUv) - 0.5; + vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution; + vec3 cellImg = texture2D(u_texture, sampleCenter).rgb; + float luma = dot(cellImg, vec3(0.299, 0.587, 0.114)); + // Darker cells → bigger magenta dots (classic risograph halftone curve). + float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56; + float dotMask = smoothstep(radius + 0.06, radius, length(cellUv)); + vec3 paper = vec3(0.975, 0.965, 0.955); + vec3 dotLayer = mix(paper, u_accent, dotMask); + + // Blend the halftone layer in where the roller is passing; leave the + // element pristine elsewhere. + vec3 base = texture2D(u_texture, uv).rgb; + gl_FragColor = vec4(mix(base, dotLayer, band), 1.0); +}`; + + // Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350)) + const SHADER_ACCENT = [0.82, 0.16, 0.47]; + let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + + function compileShader(gl, type, source) { + const sh = gl.createShader(type); + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(sh); + gl.deleteShader(sh); + throw new Error('shader compile failed: ' + info); + } + return sh; + } + + function positionShaderOverlay() { + if (!shaderState || !selectedElement) return; + const r = selectedElement.getBoundingClientRect(); + Object.assign(shaderState.canvas.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + } + + function hideShaderOverlay() { + if (!shaderState) return; + if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); + if (shaderState.canvas) shaderState.canvas.remove(); + const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + shaderState = null; + } + + async function showShaderOverlay(el, blob, rect) { + hideShaderOverlay(); + if (!blob || !el) return; + const canvas = document.createElement('canvas'); + canvas.id = PREFIX + '-shader'; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + Object.assign(canvas.style, { + position: 'fixed', + top: rect.top + 'px', left: rect.left + 'px', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', + zIndex: Z.bar - 1, + }); + document.body.appendChild(canvas); + + const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) + || canvas.getContext('experimental-webgl'); + if (!gl) { + // WebGL unavailable — fall back to a plain overlay so the user + // still sees something meaningful during generation. + canvas.remove(); + const img = document.createElement('img'); + img.src = URL.createObjectURL(blob); + img.id = PREFIX + '-shader'; + Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' }); + document.body.appendChild(img); + shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 }; + return; + } + + let program, texture; + try { + const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS); + program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error('program link failed: ' + gl.getProgramInfoLog(program)); + } + // Full-screen quad + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + -1, -1, 0, 1, + 1, -1, 1, 1, + -1, 1, 0, 0, + -1, 1, 0, 0, + 1, -1, 1, 1, + 1, 1, 1, 0, + ]), gl.STATIC_DRAW); + const posLoc = gl.getAttribLocation(program, 'a_position'); + const uvLoc = gl.getAttribLocation(program, 'a_uv'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(uvLoc); + gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8); + } catch (err) { + console.warn('[impeccable] shader setup failed:', err); + canvas.remove(); + return; + } + + // Upload the screenshot as a texture + let bitmap; + try { + bitmap = await createImageBitmap(blob); + } catch { + // Safari fallback: go via a regular Image + const imgUrl = URL.createObjectURL(blob); + const img = new Image(); + img.src = imgUrl; + await new Promise((r, rej) => { img.onload = r; img.onerror = rej; }); + bitmap = img; + URL.revokeObjectURL(imgUrl); + } + texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + if (bitmap.close) bitmap.close(); + + const uTime = gl.getUniformLocation(program, 'u_time'); + const uRes = gl.getUniformLocation(program, 'u_resolution'); + const uAccent = gl.getUniformLocation(program, 'u_accent'); + const uTex = gl.getUniformLocation(program, 'u_texture'); + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; + function frame() { + if (!shaderState) return; + const elapsed = (performance.now() - shaderState.startTime) / 1000; + const t = shaderState.reduced ? 0.0 : elapsed; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.useProgram(program); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.uniform1i(uTex, 0); + gl.uniform1f(uTime, t); + gl.uniform2f(uRes, canvas.width, canvas.height); + gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]); + gl.drawArrays(gl.TRIANGLES, 0, 6); + shaderState.rafId = requestAnimationFrame(frame); + } + frame(); } function handleAccept() { @@ -2713,6 +3600,7 @@ function init() { initHighlight(); + initAnnotOverlay(); initBar(); initActionPicker(); initGlobalBar(); diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs index 109aa768e..97163b255 100644 --- a/.cursor/skills/impeccable/scripts/live-server.mjs +++ b/.cursor/skills/impeccable/scripts/live-server.mjs @@ -18,7 +18,6 @@ import { randomUUID } from 'node:crypto'; import { spawn, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; @@ -56,8 +55,13 @@ const state = { pendingEvents: [], // browser events waiting for agent poll pendingPolls: [], // agent poll callbacks waiting for browser events exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots }; +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + function enqueueEvent(event) { if (state.pendingPolls.length > 0) { state.pendingPolls.shift()(event); @@ -134,6 +138,10 @@ function validateEvent(msg) { if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; return null; case 'accept': if (!msg.id) return 'accept: missing id'; @@ -175,6 +183,83 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { return; } + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + // --- Health --- if (p === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -250,10 +335,11 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } const absPath = path.resolve(process.cwd(), filePath); if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } - try { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(fs.readFileSync(absPath, 'utf-8')); - } catch { res.writeHead(404); res.end('File not found'); } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); return; } @@ -411,6 +497,9 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } for (const res of state.sseClients) { try { res.end(); } catch {} } state.sseClients.clear(); for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); @@ -442,12 +531,14 @@ Options: --help Show this help Endpoints: - /live.js Browser script (element picker + variant cycling) - /detect.js Detection overlay (backwards compatible) - /events SSE stream (server→browser) + POST (browser→server) - /poll Long-poll for agent CLI - /source Raw source file reader (no-HMR fallback) - /health Health check`); + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); process.exit(0); } @@ -531,6 +622,12 @@ try { state.token = randomUUID(); const portArg = args.find(a => a.startsWith('--port=')); state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); const { detectScript, liveScript } = loadBrowserScripts(); const liveScriptWithToken = diff --git a/.cursor/skills/impeccable/scripts/modern-screenshot.umd.js b/.cursor/skills/impeccable/scripts/modern-screenshot.umd.js new file mode 100644 index 000000000..a9c5208f6 --- /dev/null +++ b/.cursor/skills/impeccable/scripts/modern-screenshot.umd.js @@ -0,0 +1,14 @@ +(function(y,v){typeof exports=="object"&&typeof module!="undefined"?v(exports):typeof define=="function"&&define.amd?define(["exports"],v):(y=typeof globalThis!="undefined"?globalThis:y||self,v(y.modernScreenshot={}))})(this,function(y){"use strict";var rr=Object.defineProperty,nr=Object.defineProperties;var or=Object.getOwnPropertyDescriptors;var Z=Object.getOwnPropertySymbols;var xe=Object.prototype.hasOwnProperty,Me=Object.prototype.propertyIsEnumerable;var Oe=Math.pow,Le=(y,v,N)=>v in y?rr(y,v,{enumerable:!0,configurable:!0,writable:!0,value:N}):y[v]=N,D=(y,v)=>{for(var N in v||(v={}))xe.call(v,N)&&Le(y,N,v[N]);if(Z)for(var N of Z(v))Me.call(v,N)&&Le(y,N,v[N]);return y},M=(y,v)=>nr(y,or(v));var je=(y,v)=>{var N={};for(var R in y)xe.call(y,R)&&v.indexOf(R)<0&&(N[R]=y[R]);if(y!=null&&Z)for(var R of Z(y))v.indexOf(R)<0&&Me.call(y,R)&&(N[R]=y[R]);return N};var C=(y,v,N)=>new Promise((R,O)=>{var X=P=>{try{q(N.next(P))}catch(W){O(W)}},j=P=>{try{q(N.throw(P))}catch(W){O(W)}},q=P=>P.done?R(P.value):Promise.resolve(P.value).then(X,j);q((N=N.apply(y,v)).next())});var Be;function v(e,t){return e[13]=1,e[14]=t>>8,e[15]=t&255,e[16]=t>>8,e[17]=t&255,e}const N=112,R=72,O=89,X=115;let j;function q(){const e=new Int32Array(256);for(let t=0;t<256;t++){let r=t;for(let n=0;n<8;n++)r=r&1?3988292384^r>>>1:r>>>1;e[t]=r}return e}function P(e){let t=-1;j||(j=q());for(let r=0;r>>8;return t^-1}function W(e){const t=e.length-1;for(let r=t;r>=4;r--)if(e[r-4]===9&&e[r-3]===N&&e[r-2]===R&&e[r-1]===O&&e[r]===X)return r-3;return 0}function ae(e,t,r=!1){const n=new Uint8Array(13);t*=39.3701,n[0]=N,n[1]=R,n[2]=O,n[3]=X,n[4]=t>>>24,n[5]=t>>>16,n[6]=t>>>8,n[7]=t&255,n[8]=n[4],n[9]=n[5],n[10]=n[6],n[11]=n[7],n[12]=1;const i=P(n),a=new Uint8Array(4);if(a[0]=i>>>24,a[1]=i>>>16,a[2]=i>>>8,a[3]=i&255,r){const s=W(e);return e.set(n,s),e.set(a,s+13),e}else{const s=new Uint8Array(4);s[0]=0,s[1]=0,s[2]=0,s[3]=9;const o=new Uint8Array(54);return o.set(e,0),o.set(s,33),o.set(n,37),o.set(a,50),o}}const qe="AAlwSFlz",We="AAAJcEhZ",He="AAAACXBI";function Ve(e){let t=e.indexOf(qe);return t===-1&&(t=e.indexOf(We)),t===-1&&(t=e.indexOf(He)),t}const se="[modern-screenshot]",U=typeof window!="undefined",ze=U&&"Worker"in window,ie=U&&"atob"in window,Xe=U&&"btoa"in window,ee=U?(Be=window.navigator)==null?void 0:Be.userAgent:"",le=ee.includes("Chrome"),G=ee.includes("AppleWebKit")&&!le,te=ee.includes("Firefox"),Ge=e=>e&&"__CONTEXT__"in e,Ye=e=>e.constructor.name==="CSSFontFaceRule",Je=e=>e.constructor.name==="CSSImportRule",Ke=e=>e.constructor.name==="CSSLayerBlockRule",I=e=>e.nodeType===1,H=e=>typeof e.className=="object",ce=e=>e.tagName==="image",Qe=e=>e.tagName==="use",V=e=>I(e)&&typeof e.style!="undefined"&&!H(e),Ze=e=>e.nodeType===8,et=e=>e.nodeType===3,$=e=>e.tagName==="IMG",Y=e=>e.tagName==="VIDEO",tt=e=>e.tagName==="CANVAS",rt=e=>e.tagName==="TEXTAREA",nt=e=>e.tagName==="INPUT",ot=e=>e.tagName==="STYLE",at=e=>e.tagName==="SCRIPT",st=e=>e.tagName==="SELECT",it=e=>e.tagName==="SLOT",lt=e=>e.tagName==="IFRAME",ct=(...e)=>console.warn(se,...e);function ut(e){var r;const t=(r=e==null?void 0:e.createElement)==null?void 0:r.call(e,"canvas");return t&&(t.height=t.width=1),!!t&&"toDataURL"in t&&!!t.toDataURL("image/webp").includes("image/webp")}const re=e=>e.startsWith("data:");function ue(e,t){if(e.match(/^[a-z]+:\/\//i))return e;if(U&&e.match(/^\/\//))return window.location.protocol+e;if(e.match(/^[a-z]+:/i)||!U)return e;const r=J().implementation.createHTMLDocument(),n=r.createElement("base"),i=r.createElement("a");return r.head.appendChild(n),r.body.appendChild(i),t&&(n.href=t),i.href=e,i.href}function J(e){var t;return(t=e&&I(e)?e==null?void 0:e.ownerDocument:e)!=null?t:window.document}const K="http://www.w3.org/2000/svg";function fe(e,t,r){const n=J(r).createElementNS(K,"svg");return n.setAttributeNS(null,"width",e.toString()),n.setAttributeNS(null,"height",t.toString()),n.setAttributeNS(null,"viewBox",`0 0 ${e} ${t}`),n}function de(e,t){let r=new XMLSerializer().serializeToString(e);return t&&(r=r.replace(/[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu,"")),`data:image/svg+xml;charset=utf-8,${encodeURIComponent(r)}`}function ft(e,t="image/png",r=1){return C(this,null,function*(){try{return yield new Promise((n,i)=>{e.toBlob(a=>{a?n(a):i(new Error("Blob is null"))},t,r)})}catch(n){if(ie)return dt(e.toDataURL(t,r));throw n}})}function dt(e){var o,c;const[t,r]=e.split(","),n=(c=(o=t.match(/data:(.+);/))==null?void 0:o[1])!=null?c:void 0,i=window.atob(r),a=i.length,s=new Uint8Array(a);for(let u=0;u{const i=new FileReader;i.onload=()=>r(i.result),i.onerror=()=>n(i.error),i.onabort=()=>n(new Error(`Failed read blob to ${t}`)),t==="dataUrl"?i.readAsDataURL(e):t==="arrayBuffer"&&i.readAsArrayBuffer(e)})}const gt=e=>ge(e,"dataUrl"),mt=e=>ge(e,"arrayBuffer");function _(e,t){const r=J(t).createElement("img");return r.decoding="sync",r.loading="eager",r.src=e,r}function L(e,t){return new Promise(r=>{const{timeout:n,ownerDocument:i,onError:a,onWarn:s}=t!=null?t:{},o=typeof e=="string"?_(e,J(i)):e;let c=null,u=null;function l(){r(o),c&&clearTimeout(c),u==null||u()}if(n&&(c=setTimeout(l,n)),Y(o)){const d=o.currentSrc||o.src;if(!d)return o.poster?L(o.poster,t).then(r):l();if(o.readyState>=2)return l();const m=l,f=h=>{s==null||s("Failed video load",d,h),a==null||a(h),l()};u=()=>{o.removeEventListener("loadeddata",m),o.removeEventListener("error",f)},o.addEventListener("loadeddata",m,{once:!0}),o.addEventListener("error",f,{once:!0})}else{const d=ce(o)?o.href.baseVal:o.currentSrc||o.src;if(!d)return l();const m=()=>C(this,null,function*(){if($(o)&&"decode"in o)try{yield o.decode()}catch(h){s==null||s("Failed to decode image, trying to render anyway",o.dataset.originalSrc||d,h)}l()}),f=h=>{s==null||s("Failed image load",o.dataset.originalSrc||d,h),l()};if($(o)&&o.complete)return m();u=()=>{o.removeEventListener("load",m),o.removeEventListener("error",f)},o.addEventListener("load",m,{once:!0}),o.addEventListener("error",f,{once:!0})}})}function me(e,t){return C(this,null,function*(){V(e)&&($(e)||Y(e)?yield L(e,t):yield Promise.all(["img","video"].flatMap(r=>Array.from(e.querySelectorAll(r)).map(n=>L(n,t)))))})}const he=function(){let t=0;const r=()=>`0000${(Math.random()*Oe(36,4)<<0).toString(36)}`.slice(-4);return()=>(t+=1,`u${r()}${t}`)}();function we(e){return e==null?void 0:e.split(",").map(t=>t.trim().replace(/"|'/g,"").toLowerCase()).filter(Boolean)}let pe=0;function ht(e){const t=`${se}[#${pe}]`;return pe++,{time:r=>e&&console.time(`${t} ${r}`),timeEnd:r=>e&&console.timeEnd(`${t} ${r}`),warn:(...r)=>e&&ct(...r)}}function wt(e){return{cache:e?"no-cache":"force-cache"}}function k(e,t){return C(this,null,function*(){return Ge(e)?e:ye(e,M(D({},t),{autoDestruct:!0}))})}function ye(e,t){return C(this,null,function*(){var f,h,g,p,E;const{scale:r=1,workerUrl:n,workerNumber:i=1}=t||{},a=!!(t!=null&&t.debug),s=(f=t==null?void 0:t.features)!=null?f:!0,o=(h=e.ownerDocument)!=null?h:U?window.document:void 0,c=(p=(g=e.ownerDocument)==null?void 0:g.defaultView)!=null?p:U?window:void 0,u=new Map,l=M(D({width:0,height:0,quality:1,type:"image/png",scale:r,backgroundColor:null,style:null,filter:null,maximumCanvasSize:0,timeout:3e4,progress:null,debug:a,fetch:D({requestInit:wt((E=t==null?void 0:t.fetch)==null?void 0:E.bypassingCache),placeholderImage:"data:image/png;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",bypassingCache:!1},t==null?void 0:t.fetch),fetchFn:null,font:{},drawImageInterval:100,workerUrl:null,workerNumber:i,onCloneEachNode:null,onCloneNode:null,onEmbedNode:null,onCreateForeignObjectSvg:null,includeStyleProperties:null,autoDestruct:!1},t),{__CONTEXT__:!0,log:ht(a),node:e,ownerDocument:o,ownerWindow:c,dpi:r===1?null:96*r,svgStyleElement:be(o),svgDefsElement:o==null?void 0:o.createElementNS(K,"defs"),svgStyles:new Map,defaultComputedStyles:new Map,workers:[...Array.from({length:ze&&n&&i?i:0})].map(()=>{try{const b=new Worker(n);return b.onmessage=w=>C(this,null,function*(){var A,F,B,$e;const{url:S,result:T}=w.data;T?(F=(A=u.get(S))==null?void 0:A.resolve)==null||F.call(A,T):($e=(B=u.get(S))==null?void 0:B.reject)==null||$e.call(B,new Error(`Error receiving message from worker: ${S}`))}),b.onmessageerror=w=>{var T,A;const{url:S}=w.data;(A=(T=u.get(S))==null?void 0:T.reject)==null||A.call(T,new Error(`Error receiving message from worker: ${S}`))},b}catch(b){return l.log.warn("Failed to new Worker",b),null}}).filter(Boolean),fontFamilies:new Map,fontCssTexts:new Map,acceptOfImage:`${[ut(o)&&"image/webp","image/svg+xml","image/*","*/*"].filter(Boolean).join(",")};q=0.8`,requests:u,drawImageCount:0,tasks:[],features:s,isEnable:b=>{var w,S;return b==="restoreScrollPosition"?typeof s=="boolean"?!1:(w=s[b])!=null?w:!1:typeof s=="boolean"?s:(S=s[b])!=null?S:!0},shadowRoots:[]});l.log.time("wait until load"),yield me(e,{timeout:l.timeout,onWarn:l.log.warn}),l.log.timeEnd("wait until load");const{width:d,height:m}=pt(e,l);return l.width=d,l.height=m,l})}function be(e){if(!e)return;const t=e.createElement("style"),r=t.ownerDocument.createTextNode(` +.______background-clip--text { + background-clip: text; + -webkit-background-clip: text; +} +`);return t.appendChild(r),t}function pt(e,t){let{width:r,height:n}=t;if(I(e)&&(!r||!n)){const i=e.getBoundingClientRect();r=r||i.width||Number(e.getAttribute("width"))||0,n=n||i.height||Number(e.getAttribute("height"))||0}return{width:r,height:n}}function yt(e,t){return C(this,null,function*(){const{log:r,timeout:n,drawImageCount:i,drawImageInterval:a}=t;r.time("image to canvas");const s=yield L(e,{timeout:n,onWarn:t.log.warn}),{canvas:o,context2d:c}=bt(e.ownerDocument,t),u=()=>{try{c==null||c.drawImage(s,0,0,o.width,o.height)}catch(l){t.log.warn("Failed to drawImage",l)}};if(u(),t.isEnable("fixSvgXmlDecode"))for(let l=0;l{setTimeout(()=>{c==null||c.clearRect(0,0,o.width,o.height),u(),d()},l+a)});return t.drawImageCount=0,r.timeEnd("image to canvas"),o})}function bt(e,t){const{width:r,height:n,scale:i,backgroundColor:a,maximumCanvasSize:s}=t,o=e.createElement("canvas");o.width=Math.floor(r*i),o.height=Math.floor(n*i),o.style.width=`${r}px`,o.style.height=`${n}px`,s&&(o.width>s||o.height>s)&&(o.width>s&&o.height>s?o.width>o.height?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s):o.width>s?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s));const c=o.getContext("2d");return c&&a&&(c.fillStyle=a,c.fillRect(0,0,o.width,o.height)),{canvas:o,context2d:c}}function Se(e,t){if(e.ownerDocument)try{const a=e.toDataURL();if(a!=="data:,")return _(a,e.ownerDocument)}catch(a){t.log.warn("Failed to clone canvas",a)}const r=e.cloneNode(!1),n=e.getContext("2d"),i=r.getContext("2d");try{return n&&i&&i.putImageData(n.getImageData(0,0,e.width,e.height),0,0),r}catch(a){t.log.warn("Failed to clone canvas",a)}return r}function St(e,t){var r;try{if((r=e==null?void 0:e.contentDocument)!=null&&r.documentElement)return ne(e.contentDocument.documentElement,t)}catch(n){t.log.warn("Failed to clone iframe",n)}return e.cloneNode(!1)}function Et(e){const t=e.cloneNode(!1);return e.currentSrc&&e.currentSrc!==e.src&&(t.src=e.currentSrc,t.srcset=""),t.loading==="lazy"&&(t.loading="eager"),t}function Ct(e,t){return C(this,null,function*(){if(e.ownerDocument&&!e.currentSrc&&e.poster)return _(e.poster,e.ownerDocument);const r=e.cloneNode(!1);r.crossOrigin="anonymous",e.currentSrc&&e.currentSrc!==e.src&&(r.src=e.currentSrc);const n=r.ownerDocument;if(n){let i=!0;if(yield L(r,{onError:()=>i=!1,onWarn:t.log.warn}),!i)return e.poster?_(e.poster,e.ownerDocument):r;r.currentTime=e.currentTime,yield new Promise(s=>{r.addEventListener("seeked",s,{once:!0})});const a=n.createElement("canvas");a.width=e.offsetWidth,a.height=e.offsetHeight;try{const s=a.getContext("2d");s&&s.drawImage(r,0,0,a.width,a.height)}catch(s){return t.log.warn("Failed to clone video",s),e.poster?_(e.poster,e.ownerDocument):r}return Se(a,t)}return r})}function Tt(e,t){return tt(e)?Se(e,t):lt(e)?St(e,t):$(e)?Et(e):Y(e)?Ct(e,t):e.cloneNode(!1)}function vt(e){let t=e.sandbox;if(!t){const{ownerDocument:r}=e;try{r&&(t=r.createElement("iframe"),t.id=`__SANDBOX__${he()}`,t.width="0",t.height="0",t.style.visibility="hidden",t.style.position="fixed",r.body.appendChild(t),t.srcdoc='',e.sandbox=t)}catch(n){e.log.warn("Failed to getSandBox",n)}}return t}const At=["width","height","-webkit-text-fill-color"],Nt=["stroke","fill"];function Ee(e,t,r){const{defaultComputedStyles:n}=r,i=e.nodeName.toLowerCase(),a=H(e)&&i!=="svg",s=a?Nt.map(g=>[g,e.getAttribute(g)]).filter(([,g])=>g!==null):[],o=[a&&"svg",i,s.map((g,p)=>`${g}=${p}`).join(","),t].filter(Boolean).join(":");if(n.has(o))return n.get(o);const c=vt(r),u=c==null?void 0:c.contentWindow;if(!u)return new Map;const l=u==null?void 0:u.document;let d,m;a?(d=l.createElementNS(K,"svg"),m=d.ownerDocument.createElementNS(d.namespaceURI,i),s.forEach(([g,p])=>{m.setAttributeNS(null,g,p)}),d.appendChild(m)):d=m=l.createElement(i),m.textContent=" ",l.body.appendChild(d);const f=u.getComputedStyle(m,t),h=new Map;for(let g=f.length,p=0;pn.set(d,l));function s(c){const u=e.getPropertyValue(c),l=e.getPropertyPriority(c),d=c.lastIndexOf("-"),m=d>-1?c.substring(0,d):void 0;if(m){let f=a.get(m);f||(f=new Map,a.set(m,f)),f.set(c,[u,l])}t.get(c)===u&&!l||(m?i.push(m):n.set(c,[u,l]))}return n}function Rt(e,t,r,n){var d,m,f,h;const{ownerWindow:i,includeStyleProperties:a,currentParentNodeStyle:s}=n,o=t.style,c=i.getComputedStyle(e),u=Ee(e,null,n);s==null||s.forEach((g,p)=>{u.delete(p)});const l=Ce(c,u,a);l.delete("transition-property"),l.delete("all"),l.delete("d"),l.delete("content"),r&&(l.delete("position"),l.delete("margin-top"),l.delete("margin-right"),l.delete("margin-bottom"),l.delete("margin-left"),l.delete("margin-block-start"),l.delete("margin-block-end"),l.delete("margin-inline-start"),l.delete("margin-inline-end"),l.set("box-sizing",["border-box",""])),((d=l.get("background-clip"))==null?void 0:d[0])==="text"&&t.classList.add("______background-clip--text"),le&&(l.has("font-kerning")||l.set("font-kerning",["normal",""]),(((m=l.get("overflow-x"))==null?void 0:m[0])==="hidden"||((f=l.get("overflow-y"))==null?void 0:f[0])==="hidden")&&((h=l.get("text-overflow"))==null?void 0:h[0])==="ellipsis"&&e.scrollWidth===e.clientWidth&&l.set("text-overflow",["clip",""]));for(let g=o.length,p=0;p{o.setProperty(E,g,p)}),l}function It(e,t){(rt(e)||nt(e)||st(e))&&t.setAttribute("value",e.value)}const kt=["::before","::after"],Dt=["::-webkit-scrollbar","::-webkit-scrollbar-button","::-webkit-scrollbar-thumb","::-webkit-scrollbar-track","::-webkit-scrollbar-track-piece","::-webkit-scrollbar-corner","::-webkit-resizer"];function Pt(e,t,r,n,i){const{ownerWindow:a,svgStyleElement:s,svgStyles:o,currentNodeStyle:c}=n;if(!s||!a)return;function u(l){var w;const d=a.getComputedStyle(e,l);let m=d.getPropertyValue("content");if(!m||m==="none")return;i==null||i(m),m=m.replace(/(')|(")|(counter\(.+\))/g,"");const f=[he()],h=Ee(e,l,n);c==null||c.forEach((S,T)=>{h.delete(T)});const g=Ce(d,h,n.includeStyleProperties);g.delete("content"),g.delete("-webkit-locale"),((w=g.get("background-clip"))==null?void 0:w[0])==="text"&&t.classList.add("______background-clip--text");const p=[`content: '${m}';`];if(g.forEach(([S,T],A)=>{p.push(`${A}: ${S}${T?" !important":""};`)}),p.length===1)return;try{t.className=[t.className,...f].join(" ")}catch(S){n.log.warn("Failed to copyPseudoClass",S);return}const E=p.join(` + `);let b=o.get(E);b||(b=[],o.set(E,b)),b.push(`.${f[0]}${l}`)}kt.forEach(u),r&&Dt.forEach(u)}const Te=new Set(["symbol"]);function ve(e,t,r,n,i){return C(this,null,function*(){if(I(r)&&(ot(r)||at(r))||n.filter&&!n.filter(r))return;Te.has(t.nodeName)||Te.has(r.nodeName)?n.currentParentNodeStyle=void 0:n.currentParentNodeStyle=n.currentNodeStyle;const a=yield ne(r,n,!1,i);n.isEnable("restoreScrollPosition")&&Ut(e,a),t.appendChild(a)})}function Ae(e,t,r,n){return C(this,null,function*(){var a;let i=e.firstChild;I(e)&&e.shadowRoot&&(i=(a=e.shadowRoot)==null?void 0:a.firstChild,r.shadowRoots.push(e.shadowRoot));for(let s=i;s;s=s.nextSibling)if(!Ze(s))if(I(s)&&it(s)&&typeof s.assignedNodes=="function"){const o=s.assignedNodes();for(let c=0;ce.clientHeight||e.scrollWidth>e.clientWidth)}const p=(d=h.get("text-transform"))==null?void 0:d[0],E=we((m=h.get("font-family"))==null?void 0:m[0]),b=E?w=>{p==="uppercase"?w=w.toUpperCase():p==="lowercase"?w=w.toLowerCase():p==="capitalize"&&(w=w[0].toUpperCase()+w.substring(1)),E.forEach(S=>{let T=s.get(S);T||s.set(S,T=new Set),w.split("").forEach(A=>T.add(A))})}:void 0;return Pt(e,f,g,t,b),It(e,f),Y(e)||(yield Ae(e,f,t,b)),yield o==null?void 0:o(f),f}const c=e.cloneNode(!1);return yield Ae(e,c,t),yield o==null?void 0:o(c),c})}function Ne(e){if(e.ownerDocument=void 0,e.ownerWindow=void 0,e.svgStyleElement=void 0,e.svgDefsElement=void 0,e.svgStyles.clear(),e.defaultComputedStyles.clear(),e.sandbox){try{e.sandbox.remove()}catch(t){e.log.warn("Failed to destroyContext",t)}e.sandbox=void 0}e.workers=[],e.fontFamilies.clear(),e.fontCssTexts.clear(),e.requests.clear(),e.tasks=[],e.shadowRoots=[]}function Bt(e){const o=e,{url:t,timeout:r,responseType:n}=o,i=je(o,["url","timeout","responseType"]),a=new AbortController,s=r?setTimeout(()=>a.abort(),r):void 0;return fetch(t,D({signal:a.signal},i)).then(c=>{if(!c.ok)throw new Error("Failed fetch, not 2xx response",{cause:c});switch(n){case"arrayBuffer":return c.arrayBuffer();case"dataUrl":return c.blob().then(gt);case"text":default:return c.text()}}).finally(()=>clearTimeout(s))}function z(e,t){const{url:r,requestType:n="text",responseType:i="text",imageDom:a}=t;let s=r;const{timeout:o,acceptOfImage:c,requests:u,fetchFn:l,fetch:{requestInit:d,bypassingCache:m,placeholderImage:f},font:h,workers:g,fontFamilies:p}=e;n==="image"&&(G||te)&&e.drawImageCount++;let E=u.get(r);if(!E){m&&m instanceof RegExp&&m.test(s)&&(s+=(/\?/.test(s)?"&":"?")+new Date().getTime());const b=n.startsWith("font")&&h&&h.minify,w=new Set;b&&n.split(";")[1].split(",").forEach(F=>{p.has(F)&&p.get(F).forEach(B=>w.add(B))});const S=b&&w.size,T=D({url:s,timeout:o,responseType:S?"arrayBuffer":i,headers:n==="image"?{accept:c}:void 0},d);E={type:n,resolve:void 0,reject:void 0,response:null},E.response=C(this,null,function*(){if(l&&n==="image"){const A=yield l(r);if(A)return A}return!G&&r.startsWith("http")&&g.length?new Promise((A,F)=>{g[u.size&g.length-1].postMessage(D({rawUrl:r},T)),E.resolve=A,E.reject=F}):Bt(T)}).catch(A=>{if(u.delete(r),n==="image"&&f)return e.log.warn("Failed to fetch image base64, trying to use placeholder image",s),typeof f=="string"?f:f(a);throw A}),u.set(r,E)}return E.response}function Re(e,t,r,n){return C(this,null,function*(){if(!Ie(e))return e;for(const[i,a]of $t(e,t))try{const s=yield z(r,{url:a,requestType:n?"image":"text",responseType:"dataUrl"});e=e.replace(Lt(i),`$1${s}$3`)}catch(s){r.log.warn("Failed to fetch css data url",i,s)}return e})}function Ie(e){return/url\((['"]?)([^'"]+?)\1\)/.test(e)}const ke=/url\((['"]?)([^'"]+?)\1\)/g;function $t(e,t){const r=[];return e.replace(ke,(n,i,a)=>(r.push([a,ue(a,t)]),n)),r.filter(([n])=>!re(n))}function Lt(e){const t=e.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${t})(['"]?\\))`,"g")}const xt=["background-image","border-image-source","-webkit-border-image","-webkit-mask-image","list-style-image"];function Mt(e,t){return xt.map(r=>{const n=e.getPropertyValue(r);return!n||n==="none"?null:((G||te)&&t.drawImageCount++,Re(n,null,t,!0).then(i=>{!i||n===i||e.setProperty(r,i,e.getPropertyPriority(r))}))}).filter(Boolean)}function Ot(e,t){if($(e)){const r=e.currentSrc||e.src;if(!re(r))return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.srcset="",e.dataset.originalSrc=r,e.src=n||"")})];(G||te)&&t.drawImageCount++}else if(H(e)&&!re(e.href.baseVal)){const r=e.href.baseVal;return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.dataset.originalSrc=r,e.href.baseVal=n||"")})]}return[]}function jt(e,t){var o;const{ownerDocument:r,svgDefsElement:n}=t,i=(o=e.getAttribute("href"))!=null?o:e.getAttribute("xlink:href");if(!i)return[];const[a,s]=i.split("#");if(s){const c=`#${s}`,u=t.shadowRoots.reduce((l,d)=>l!=null?l:d.querySelector(`svg ${c}`),r==null?void 0:r.querySelector(`svg ${c}`));if(a&&e.setAttribute("href",c),n!=null&&n.querySelector(c))return[];if(u)return n==null||n.appendChild(u.cloneNode(!0)),[];if(a)return[z(t,{url:a,responseType:"text"}).then(l=>{n==null||n.insertAdjacentHTML("beforeend",l)})]}return[]}function De(e,t){const{tasks:r}=t;I(e)&&(($(e)||ce(e))&&r.push(...Ot(e,t)),Qe(e)&&r.push(...jt(e,t))),V(e)&&r.push(...Mt(e.style,t)),e.childNodes.forEach(n=>{De(n,t)})}function qt(e,t){return C(this,null,function*(){const{ownerDocument:r,svgStyleElement:n,fontFamilies:i,fontCssTexts:a,tasks:s,font:o}=t;if(!(!r||!n||!i.size))if(o&&o.cssText){const c=Ue(o.cssText,t);n.appendChild(r.createTextNode(`${c} +`))}else{const c=Array.from(r.styleSheets).filter(f=>{try{return"cssRules"in f&&!!f.cssRules.length}catch(h){return t.log.warn(`Error while reading CSS rules from ${f.href}`,h),!1}}),u=r.implementation.createHTMLDocument(""),l=u.createElement("style");u.head.appendChild(l);const d=l.sheet;yield Promise.all(c.flatMap(f=>Array.from(f.cssRules).map(h=>C(this,null,function*(){if(Je(h)){const g=h.href;let p="";try{p=yield z(t,{url:g,requestType:"text",responseType:"text"})}catch(b){t.log.warn(`Error fetch remote css import from ${g}`,b)}const E=p.replace(ke,(b,w,S)=>b.replace(S,ue(S,g)));for(const b of Ht(E))try{d.insertRule(b,d.cssRules.length)}catch(w){t.log.warn("Error inserting rule from remote css import",{rule:b,error:w})}}})))),d.cssRules.length&&c.push(d);const m=[];c.forEach(f=>{oe(f.cssRules,m)}),m.filter(f=>{var h;return Ye(f)&&Ie(f.style.getPropertyValue("src"))&&((h=we(f.style.getPropertyValue("font-family")))==null?void 0:h.some(g=>i.has(g)))}).forEach(f=>{const h=f,g=a.get(h.cssText);g?n.appendChild(r.createTextNode(`${g} +`)):s.push(Re(h.cssText,h.parentStyleSheet?h.parentStyleSheet.href:null,t).then(p=>{p=Ue(p,t),a.set(h.cssText,p),n.appendChild(r.createTextNode(`${p} +`))}))})}})}const Wt=/(\/\*[\s\S]*?\*\/)/g,Pe=/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi;function Ht(e){if(e==null)return[];const t=[];let r=e.replace(Wt,"");for(;;){const a=Pe.exec(r);if(!a)break;t.push(a[0])}r=r.replace(Pe,"");const n=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,i=new RegExp("((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})","gi");for(;;){let a=n.exec(r);if(a)i.lastIndex=n.lastIndex;else if(a=i.exec(r),a)n.lastIndex=i.lastIndex;else break;t.push(a[0])}return t}const Vt=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,zt=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Ue(e,t){const{font:r}=t,n=r?r==null?void 0:r.preferredFormat:void 0;return n?e.replace(zt,i=>{for(;;){const[a,,s]=Vt.exec(i)||[];if(!s)return"";if(s===n)return`src: ${a};`}}):e}function oe(e,t=[]){for(const r of Array.from(e))Ke(r)?t.push(...oe(r.cssRules)):"cssRules"in r?oe(r.cssRules,t):t.push(r);return t}const Xt=/\bx?link:?href\s*=\s*["'](?!data:)[^"']+["']/i;function Gt(e){return Xt.test(e.innerHTML)}function _e(e,t){return C(this,null,function*(){const r=yield k(e,t);if(I(r.node)&&H(r.node)&&!Gt(r.node))return r.node;const{ownerDocument:n,log:i,tasks:a,svgStyleElement:s,svgDefsElement:o,svgStyles:c,font:u,progress:l,autoDestruct:d,onCloneNode:m,onEmbedNode:f,onCreateForeignObjectSvg:h}=r;i.time("clone node");const g=yield ne(r.node,r,!0);if(s&&n){let S="";c.forEach((T,A)=>{S+=`${T.join(`, +`)} { + ${A} +} +`}),s.appendChild(n.createTextNode(S))}i.timeEnd("clone node"),yield m==null?void 0:m(g),u!==!1&&I(g)&&(i.time("embed web font"),yield qt(g,r),i.timeEnd("embed web font")),i.time("embed node"),De(g,r);const p=a.length;let E=0;const b=()=>C(this,null,function*(){for(;;){const S=a.pop();if(!S)break;try{yield S}catch(T){r.log.warn("Failed to run task",T)}l==null||l(++E,p)}});l==null||l(E,p),yield Promise.all([...Array.from({length:4})].map(b)),i.timeEnd("embed node"),yield f==null?void 0:f(g);const w=Yt(g,r);return o&&w.insertBefore(o,w.children[0]),s&&w.insertBefore(s,w.children[0]),d&&Ne(r),yield h==null?void 0:h(w),w})}function Yt(e,t){const{width:r,height:n}=t,i=fe(r,n,e.ownerDocument),a=i.ownerDocument.createElementNS(i.namespaceURI,"foreignObject");return a.setAttributeNS(null,"x","0%"),a.setAttributeNS(null,"y","0%"),a.setAttributeNS(null,"width","100%"),a.setAttributeNS(null,"height","100%"),a.append(e),i.appendChild(a),i}function Q(e,t){return C(this,null,function*(){var s;const r=yield k(e,t),n=yield _e(r),i=de(n,r.isEnable("removeControlCharacter"));r.autoDestruct||(r.svgStyleElement=be(r.ownerDocument),r.svgDefsElement=(s=r.ownerDocument)==null?void 0:s.createElementNS(K,"defs"),r.svgStyles.clear());const a=_(i,n.ownerDocument);return yield yt(a,r)})}function Jt(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,type:i,quality:a,dpi:s}=r,o=yield Q(r);n.time("canvas to blob");const c=yield ft(o,i,a);if(["image/png","image/jpeg"].includes(i)&&s){const u=yield mt(c.slice(0,33));let l=new Uint8Array(u);return i==="image/png"?l=ae(l,s):i==="image/jpeg"&&(l=v(l,s)),n.timeEnd("canvas to blob"),new Blob([l,c.slice(33)],{type:i})}return n.timeEnd("canvas to blob"),c})}function x(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,quality:i,type:a,dpi:s}=r,o=yield Q(r);n.time("canvas to data url");let c=o.toDataURL(a,i);if(["image/png","image/jpeg"].includes(a)&&s&&ie&&Xe){const[u,l]=c.split(",");let d=0,m=!1;if(a==="image/png"){const w=Ve(l);w>=0?(d=Math.ceil((w+28)/3)*4,m=!0):d=33/3*4}else a==="image/jpeg"&&(d=18/3*4);const f=l.substring(0,d),h=l.substring(d),g=window.atob(f),p=new Uint8Array(g.length);for(let w=0;w mirroring selectedElement's bounding rect. Click (no + // drag) drops a comment pin; drag paints a magenta SVG stroke. All coords + // are stored in element-local CSS px so they survive scroll / resize and + // correlate directly with the captured PNG. + // --------------------------------------------------------------------------- + + const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click + const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it + let annotOverlayEl = null; + let annotSvgEl = null; + let annotPinsEl = null; + let annotClearChipEl = null; + let annotState = { comments: [], strokes: [] }; + let annotActive = false; + // `annotPointer` is either: + // { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin + // { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin + let annotPointer = null; + let annotEditing = null; // { idx, input, wrapEl } + let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete + + function initAnnotOverlay() { + annotOverlayEl = document.createElement('div'); + annotOverlayEl.id = PREFIX + '-annot'; + Object.assign(annotOverlayEl.style, { + position: 'fixed', top: '0', left: '0', width: '0', height: '0', + pointerEvents: 'auto', zIndex: Z.highlight + 2, + display: 'none', overflow: 'visible', + cursor: 'crosshair', touchAction: 'none', + }); + + annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + annotSvgEl.id = PREFIX + '-annot-svg'; + Object.assign(annotSvgEl.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', + // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via + // pointer-events=stroke so gaps still fall through to the overlay. + pointerEvents: 'none', overflow: 'visible', + }); + annotOverlayEl.appendChild(annotSvgEl); + + annotPinsEl = document.createElement('div'); + annotPinsEl.id = PREFIX + '-annot-pins'; + Object.assign(annotPinsEl.style, { + position: 'absolute', inset: '0', + pointerEvents: 'none', + }); + annotOverlayEl.appendChild(annotPinsEl); + + annotClearChipEl = document.createElement('div'); + annotClearChipEl.id = PREFIX + '-annot-clear'; + annotClearChipEl.dataset.annotClear = 'true'; + annotClearChipEl.textContent = 'Clear'; + Object.assign(annotClearChipEl.style, { + position: 'absolute', top: '8px', right: '8px', + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '10px', fontWeight: '500', + letterSpacing: '0.08em', textTransform: 'uppercase', + padding: '5px 12px', borderRadius: '999px', + cursor: 'pointer', pointerEvents: 'auto', + display: 'none', userSelect: 'none', + boxShadow: '0 1px 3px rgba(0,0,0,0.2)', + }); + annotOverlayEl.appendChild(annotClearChipEl); + + annotOverlayEl.addEventListener('pointerdown', onAnnotDown); + annotOverlayEl.addEventListener('pointermove', onAnnotMove); + annotOverlayEl.addEventListener('pointerup', onAnnotUp); + annotOverlayEl.addEventListener('pointercancel', onAnnotUp); + document.body.appendChild(annotOverlayEl); + } + + function updateClearChip() { + if (!annotClearChipEl) return; + const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0; + annotClearChipEl.style.display = hasAny ? 'block' : 'none'; + } + + function showAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + annotActive = true; + positionAnnotOverlay(el); + annotOverlayEl.style.display = 'block'; + } + + function hideAnnotOverlay() { + annotActive = false; + if (annotOverlayEl) annotOverlayEl.style.display = 'none'; + // Drop any in-progress edit without touching annotState — clearAnnotations + // (if the caller is exiting configure mode) handles state reset. + annotEditing = null; + } + + function positionAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + const r = el.getBoundingClientRect(); + Object.assign(annotOverlayEl.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height); + } + + function clearAnnotations() { + annotState.comments = []; + annotState.strokes = []; + if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + if (annotPinsEl) annotPinsEl.innerHTML = ''; + annotPointer = null; + annotEditing = null; + annotLastPinClick = { idx: -1, time: 0 }; + updateClearChip(); + } + + // Rebuild the SVG layer. Each stroke gets a wider invisible hit path + // beneath the visible magenta path so clicks register on thin lines. + function redrawStrokes() { + while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + annotState.strokes.forEach((s, idx) => { + const d = pointsToPath(s.points); + const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + hit.setAttribute('d', d); + hit.setAttribute('stroke', 'transparent'); + hit.setAttribute('stroke-width', '16'); + hit.setAttribute('stroke-linecap', 'round'); + hit.setAttribute('stroke-linejoin', 'round'); + hit.setAttribute('fill', 'none'); + hit.setAttribute('pointer-events', 'stroke'); + hit.style.cursor = 'pointer'; + hit.dataset.annotStroke = String(idx); + annotSvgEl.appendChild(hit); + const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + visible.setAttribute('d', d); + visible.setAttribute('stroke', C.brand); + visible.setAttribute('stroke-width', '3'); + visible.setAttribute('stroke-linecap', 'round'); + visible.setAttribute('stroke-linejoin', 'round'); + visible.setAttribute('fill', 'none'); + visible.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(visible); + }); + updateClearChip(); + } + + function localCoords(e) { + const rect = annotOverlayEl.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + function onAnnotDown(e) { + if (!annotActive) return; + + // 1) Clear chip → wipe all annotations + if (e.target.closest?.('[data-annot-clear]')) { + if (annotEditing) annotEditing = null; + clearAnnotations(); + renderAllPins(); + redrawStrokes(); + e.stopPropagation(); e.preventDefault(); + return; + } + + // 2) Stroke hit path → delete that stroke + const strokeHit = e.target.closest?.('[data-annot-stroke]'); + if (strokeHit) { + const idx = parseInt(strokeHit.dataset.annotStroke, 10); + if (Number.isInteger(idx)) { + annotState.strokes.splice(idx, 1); + redrawStrokes(); + } + e.stopPropagation(); e.preventDefault(); + return; + } + + // 3) Pin → drag, edit, or delete-on-double-click + const pinWrap = e.target.closest?.('[data-annot-pin]'); + if (pinWrap) { + const idx = parseInt(pinWrap.dataset.annotPin, 10); + if (!Number.isInteger(idx)) return; + // Double-click (two pointerdowns on the same pin within window) → delete. + const now = Date.now(); + if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) { + if (annotEditing && annotEditing.idx === idx) annotEditing = null; + annotState.comments.splice(idx, 1); + annotLastPinClick = { idx: -1, time: 0 }; + renderAllPins(); + e.stopPropagation(); e.preventDefault(); + return; + } + annotLastPinClick = { idx, time: now }; + // If editing a different pin, commit that edit before starting here. + if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin(); + // If already editing THIS pin and the user clicked the dot, let the + // input keep focus (don't start a drag — the click wasn't meant as one). + if (annotEditing && annotEditing.idx === idx) return; + const p = localCoords(e); + const pin = annotState.comments[idx]; + annotPointer = { + kind: 'pin', idx, + startPointer: p, + startPin: { x: pin.x, y: pin.y }, + moved: false, + }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + return; + } + + // 4) Empty area → commit any open edit, then start new annotation + if (annotEditing) { + finalizeEditingPin(); + e.stopPropagation(); e.preventDefault(); + return; + } + const p = localCoords(e); + annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + } + + function onAnnotMove(e) { + if (!annotActive || !annotPointer) return; + const p = localCoords(e); + + if (annotPointer.kind === 'pin') { + const dx = p.x - annotPointer.startPointer.x; + const dy = p.y - annotPointer.startPointer.y; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + } + const pin = annotState.comments[annotPointer.idx]; + if (!pin) { annotPointer = null; return; } + pin.x = annotPointer.startPin.x + dx; + pin.y = annotPointer.startPin.y + dy; + renderAllPins(); + e.stopPropagation(); + return; + } + + // kind === 'new' + const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + strokeEl.setAttribute('stroke', C.brand); + strokeEl.setAttribute('stroke-width', '3'); + strokeEl.setAttribute('stroke-linecap', 'round'); + strokeEl.setAttribute('stroke-linejoin', 'round'); + strokeEl.setAttribute('fill', 'none'); + strokeEl.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(strokeEl); + annotPointer.strokeEl = strokeEl; + annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]]; + } + annotPointer.strokePoints.push([p.x, p.y]); + annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints)); + e.stopPropagation(); + } + + function onAnnotUp(e) { + if (!annotActive || !annotPointer) return; + + if (annotPointer.kind === 'pin') { + const wasDrag = annotPointer.moved; + const idx = annotPointer.idx; + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + if (wasDrag) { + // A drag is an intentional reposition; a follow-up click shouldn't be + // interpreted as a double-click-to-delete. + annotLastPinClick = { idx: -1, time: 0 }; + } else { + beginEditPin(idx); + } + e.stopPropagation(); + return; + } + + // kind === 'new' + const wasDrag = annotPointer.moved; + if (wasDrag) { + annotState.strokes.push({ points: annotPointer.strokePoints }); + // Swap the temporary preview SVG path for the full render with hit paths. + redrawStrokes(); + } else { + const idx = annotState.comments.length; + annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' }); + renderAllPins(); + beginEditPin(idx); + } + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + e.stopPropagation(); + } + + function pointsToPath(points) { + if (!points || points.length === 0) return ''; + let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1); + for (let i = 1; i < points.length; i++) { + d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1); + } + return d; + } + + function renderAllPins() { + annotPinsEl.innerHTML = ''; + annotState.comments.forEach((c, idx) => { + annotPinsEl.appendChild(buildPinElement(c, idx)); + }); + updateClearChip(); + } + + function buildPinElement(comment, idx) { + const interactive = idx >= 0; + const wrap = document.createElement('div'); + if (interactive) wrap.dataset.annotPin = String(idx); + Object.assign(wrap.style, { + position: 'absolute', + left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px', + pointerEvents: interactive ? 'auto' : 'none', + display: 'flex', alignItems: 'flex-start', gap: '6px', + cursor: interactive ? 'grab' : 'default', + touchAction: 'none', + }); + const dot = document.createElement('div'); + Object.assign(dot.style, { + width: '14px', height: '14px', borderRadius: '50%', + background: C.brand, border: '2px solid ' + C.white, + boxShadow: '0 1px 3px rgba(0,0,0,0.25)', + flexShrink: '0', + }); + wrap.appendChild(dot); + + if (comment.text) { + const bubble = document.createElement('div'); + bubble.textContent = comment.text; + Object.assign(bubble.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + marginTop: '-2px', maxWidth: '220px', + pointerEvents: 'none', whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }); + wrap.appendChild(bubble); + } + return wrap; + } + + function beginEditPin(idx) { + const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]'); + if (!wrapEl) return; + // Strip any existing bubble (but keep the dot) + wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove()); + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Note…'; + Object.assign(input.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + border: '1px solid ' + C.brand, + outline: 'none', marginTop: '-2px', + width: '220px', pointerEvents: 'auto', + }); + const originalText = annotState.comments[idx].text || ''; + input.value = originalText; + wrapEl.appendChild(input); + annotEditing = { idx, input, wrapEl, originalText }; + input.addEventListener('keydown', onAnnotInputKey, true); + input.addEventListener('blur', () => { + // Fires on both focus-loss and programmatic blur; commit unless we + // already handled it. + if (annotEditing && annotEditing.input === input) finalizeEditingPin(); + }); + // Stop clicks/pointerdowns inside the input from bubbling to the overlay + ['pointerdown', 'click'].forEach(ev => { + input.addEventListener(ev, e => e.stopPropagation()); + }); + setTimeout(() => input.focus(), 0); + } + + function onAnnotInputKey(e) { + if (e.key === 'Enter') { + e.preventDefault(); e.stopPropagation(); + finalizeEditingPin(); + } else if (e.key === 'Escape') { + e.preventDefault(); e.stopPropagation(); + cancelEditingPin(); + } else { + // Keep arrows / backspace from hitting global handlers + e.stopPropagation(); + } + } + + function finalizeEditingPin() { + if (!annotEditing) return; + const { idx, input } = annotEditing; + const text = input.value.trim(); + annotEditing = null; + if (text) annotState.comments[idx].text = text; + else annotState.comments.splice(idx, 1); + renderAllPins(); + } + + function cancelEditingPin() { + if (!annotEditing) return; + const { idx, originalText } = annotEditing; + annotEditing = null; + // If the pin had text before this edit, revert to it. If it was a + // just-created empty pin, Escape removes it. + if (originalText) { + annotState.comments[idx].text = originalText; + } else { + annotState.comments.splice(idx, 1); + } + renderAllPins(); + } + + // Build a detached annotation subtree suitable for injection into the clone + // modern-screenshot creates. Coordinates are element-local so this slots + // straight into an element that's been made position:relative. Takes an + // explicit snapshot so it works after annotState has been cleared. + function buildAnnotationsForCapture(rect, snapshot) { + const comments = snapshot ? snapshot.comments : annotState.comments; + const strokes = snapshot ? snapshot.strokes : annotState.strokes; + if (comments.length === 0 && strokes.length === 0) return null; + const wrap = document.createElement('div'); + Object.assign(wrap.style, { + position: 'absolute', top: '0', left: '0', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', overflow: 'visible', + }); + if (strokes.length > 0) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height); + Object.assign(svg.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', overflow: 'visible', + }); + for (const s of strokes) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('stroke', C.brand); + path.setAttribute('stroke-width', '3'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('fill', 'none'); + path.setAttribute('d', pointsToPath(s.points)); + svg.appendChild(path); + } + wrap.appendChild(svg); + } + for (const c of comments) { + // idx=-1 means non-interactive; pointerEvents stay off in the clone + wrap.appendChild(buildPinElement(c, -1)); + } + return wrap; } // --------------------------------------------------------------------------- @@ -416,8 +901,10 @@ fontSize: '11px', color: C.ash, whiteSpace: 'nowrap', marginLeft: 'auto', }); + // Variants currently arrive atomically in a single file edit, so a + // per-variant counter would lie. Say what's true. status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + (arrivedVariants + 1) + ' of ' + expectedVariants + '...' + ? 'Generating ' + expectedVariants + ' variants...' : 'Done'; row.appendChild(status); @@ -838,6 +1325,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + hideShaderOverlay(); updateBarContent('cycling'); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -860,6 +1348,10 @@ positionBar(); showHighlight(selectedElement); } + if (annotActive) positionAnnotOverlay(selectedElement); + // Shader overlay (via debug P toggle or generation) is repositioned + // by its own branch below; debug no longer has a separate overlay. + if (shaderState) positionShaderOverlay(); scrollRaf = requestAnimationFrame(tick); } scrollRaf = requestAnimationFrame(tick); @@ -937,6 +1429,8 @@ } hideBar(); hideHighlight(); + hideShaderOverlay(); + hideAnnotOverlay(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } clearSession(); @@ -976,6 +1470,8 @@ if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { hideBar(); stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -989,15 +1485,19 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); } function handleKeyDown(e) { + // When the annotation input is focused, let it handle its own keys. + if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); state = 'PICKING'; return; } + if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { hideHighlight(); state = 'IDLE'; return; } @@ -1024,6 +1524,8 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); return; @@ -1035,6 +1537,8 @@ } else { // CONFIGURING: re-select the new element and refresh the bar selectedElement = next; + clearAnnotations(); + showAnnotOverlay(next); showBar('configure'); startScrollTracking(); } @@ -1056,25 +1560,408 @@ const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; + // Commit any pending pin edit BEFORE we snapshot annotations. + if (annotEditing) finalizeEditingPin(); + currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; - sendEvent({ + // Flip to GENERATING immediately so the bar morphs without waiting on + // capture + upload. The event is emitted from captureAndEmit() once the + // screenshot is uploaded (or capture fails — we still emit, just without + // screenshotPath). + const elForCapture = selectedElement; + const captureRect = elForCapture.getBoundingClientRect(); + const snapshot = { + comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), + strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })), + }; + const basePayload = { type: 'generate', id: currentSessionId, action: selectedAction, freeformPrompt: prompt || undefined, count: selectedCount, pageUrl: location.pathname, - element: extractContext(selectedElement), - }); + element: extractContext(elForCapture), + }; + if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments; + if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes; + + // Hide the interactive overlay so it doesn't linger during generation. + hideAnnotOverlay(); + clearAnnotations(); state = 'GENERATING'; showBar('generating'); saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + + captureAndEmit(elForCapture, basePayload, snapshot, captureRect); + } + + // --------------------------------------------------------------------------- + // Screenshot capture + upload + // --------------------------------------------------------------------------- + + let msLoadPromise = null; + function loadModernScreenshot() { + if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot); + if (msLoadPromise) return msLoadPromise; + msLoadPromise = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; + s.onload = () => resolve(window.modernScreenshot); + s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; + document.head.appendChild(s); + }); + return msLoadPromise; + } + + // Collect @font-face rules from every stylesheet on the page. Cross-origin + // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules + // access, so modern-screenshot can't embed them on its own — the resulting + // SVG falls back to system fonts and text re-wraps + renders with different + // weight. We fetch the raw CSS text (CORS-permitted for these providers), + // extract @font-face blocks, inline the referenced font files as base64 + // data URIs (SVGs rasterized via canvas can't fetch external resources, + // so URLs inside the SVG silently fail without this), and pass the result + // to modern-screenshot as font.cssText. + const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i; + const FONT_MIME = { + woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject', + }; + function bufferToBase64(buf) { + const bytes = new Uint8Array(buf); + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); + } + async function inlineFontUrls(cssText) { + const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g; + const urls = new Set(); + let m; + while ((m = urlRe.exec(cssText))) { + if (FONT_EXT_RE.test(m[2])) urls.add(m[2]); + } + const map = new Map(); + await Promise.all([...urls].map(async (url) => { + try { + const res = await fetch(url); + if (!res.ok) return; + const buf = await res.arrayBuffer(); + const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2'; + const mime = FONT_MIME[ext] || 'application/octet-stream'; + map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf)); + } catch { /* skip; fall through to URL */ } + })); + return cssText.replace(urlRe, (orig, q, url) => { + const data = map.get(url); + return data ? 'url(' + q + data + q + ')' : orig; + }); + } + async function collectFontCssText() { + const chunks = []; + const fontFaceRe = /@font-face\s*\{[^}]*\}/g; + for (const sheet of document.styleSheets) { + try { + const rules = sheet.cssRules; + for (const rule of rules) { + if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) { + chunks.push(rule.cssText); + } + } + } catch { + if (!sheet.href) continue; + try { + const res = await fetch(sheet.href); + if (!res.ok) continue; + const text = await res.text(); + let m2; + while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]); + } catch { /* ignore; capture is best-effort */ } + } + } + if (chunks.length === 0) return ''; + return inlineFontUrls(chunks.join('\n')); + } + + // Capture the element (with current annotations baked in) and return a PNG + // Blob. Shared between the Go flow (uploads it to the server) and the + // debug toggle (displays it as an overlay for side-by-side comparison). + async function captureElementToBlob(el, snapshot, rect) { + try { if (document.fonts?.ready) await document.fonts.ready; } catch {} + const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); + let annotNode = null; + let savedPosition = null; + if (hasAnnotations) { + const pos = getComputedStyle(el).position; + if (pos === 'static') { + savedPosition = el.style.position; + el.style.position = 'relative'; + } + annotNode = buildAnnotationsForCapture(rect, snapshot); + el.appendChild(annotNode); + } + try { + const ms = await loadModernScreenshot(); + const fontCssText = await collectFontCssText(); + return await ms.domToBlob(el, { + scale: Math.min(window.devicePixelRatio || 1, 2), + backgroundColor: getComputedStyle(document.body).backgroundColor || '#ffffff', + font: fontCssText ? { cssText: fontCssText } : undefined, + }); + } finally { + if (annotNode) annotNode.remove(); + if (savedPosition !== null) el.style.position = savedPosition; + } + } + + async function captureAndEmit(el, basePayload, snapshot, rect) { + let screenshotPath; + let blob; + try { + blob = await captureElementToBlob(el, snapshot, rect); + } catch (err) { + console.warn('[impeccable] capture failed, proceeding without screenshot:', err); + } + // Light up the shader overlay the moment capture is ready — no reason to + // wait for the upload to complete before the user sees something alive. + if (blob && state === 'GENERATING') { + showShaderOverlay(el, blob, rect); + } + if (blob) { + try { + const uploadRes = await fetch( + 'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) + + '&eventId=' + encodeURIComponent(basePayload.id), + { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob }, + ); + if (uploadRes.ok) { + const { path: p } = await uploadRes.json(); + screenshotPath = p; + } else { + console.warn('[impeccable] annotation upload failed:', uploadRes.status); + } + } catch (err) { + console.warn('[impeccable] annotation upload failed:', err); + } + } + sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); + } + + // --------------------------------------------------------------------------- + // Shader overlay — renders the captured screenshot as a WebGL texture and + // runs an editorial "ink-wash" fragment shader over it during generation. + // A single rolling band sweeps top-to-bottom, desaturating + tinting magenta + // and leaving a soft trail. Makes the wait feel like a letterpress scan + // instead of a dead spinner. + // --------------------------------------------------------------------------- + + const SHADER_VS = `attribute vec2 a_position; +attribute vec2 a_uv; +varying vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_position, 0.0, 1.0); +}`; + + const SHADER_FS = `precision highp float; +uniform sampler2D u_texture; +uniform float u_time; +uniform vec2 u_resolution; +uniform vec3 u_accent; +varying vec2 v_uv; + +// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at +// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean +// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below" +// failure that reversed-edge smoothstep would give). +float bandAt(float d, float leadW, float trailW) { + float above = smoothstep(-leadW, 0.0, d); + float below = 1.0 - smoothstep(0.0, trailW, d); + return above * below; +} + +void main() { + vec2 uv = v_uv; + // Roller sweeps top-to-bottom with small overshoot so each cycle enters + // and exits the element cleanly. + float phase = fract(u_time / 3.4); + float y = phase * 1.25 - 0.12; + float band = bandAt(uv.y - y, 0.05, 0.32); + + // Halftone cell grid (fixed ~10 px pitch). + float cellPx = 10.0; + vec2 gridUv = uv * u_resolution / cellPx; + vec2 cellId = floor(gridUv); + vec2 cellUv = fract(gridUv) - 0.5; + vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution; + vec3 cellImg = texture2D(u_texture, sampleCenter).rgb; + float luma = dot(cellImg, vec3(0.299, 0.587, 0.114)); + // Darker cells → bigger magenta dots (classic risograph halftone curve). + float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56; + float dotMask = smoothstep(radius + 0.06, radius, length(cellUv)); + vec3 paper = vec3(0.975, 0.965, 0.955); + vec3 dotLayer = mix(paper, u_accent, dotMask); + + // Blend the halftone layer in where the roller is passing; leave the + // element pristine elsewhere. + vec3 base = texture2D(u_texture, uv).rgb; + gl_FragColor = vec4(mix(base, dotLayer, band), 1.0); +}`; + + // Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350)) + const SHADER_ACCENT = [0.82, 0.16, 0.47]; + let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + + function compileShader(gl, type, source) { + const sh = gl.createShader(type); + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(sh); + gl.deleteShader(sh); + throw new Error('shader compile failed: ' + info); + } + return sh; + } + + function positionShaderOverlay() { + if (!shaderState || !selectedElement) return; + const r = selectedElement.getBoundingClientRect(); + Object.assign(shaderState.canvas.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + } + + function hideShaderOverlay() { + if (!shaderState) return; + if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); + if (shaderState.canvas) shaderState.canvas.remove(); + const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + shaderState = null; + } + + async function showShaderOverlay(el, blob, rect) { + hideShaderOverlay(); + if (!blob || !el) return; + const canvas = document.createElement('canvas'); + canvas.id = PREFIX + '-shader'; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + Object.assign(canvas.style, { + position: 'fixed', + top: rect.top + 'px', left: rect.left + 'px', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', + zIndex: Z.bar - 1, + }); + document.body.appendChild(canvas); + + const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) + || canvas.getContext('experimental-webgl'); + if (!gl) { + // WebGL unavailable — fall back to a plain overlay so the user + // still sees something meaningful during generation. + canvas.remove(); + const img = document.createElement('img'); + img.src = URL.createObjectURL(blob); + img.id = PREFIX + '-shader'; + Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' }); + document.body.appendChild(img); + shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 }; + return; + } + + let program, texture; + try { + const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS); + program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error('program link failed: ' + gl.getProgramInfoLog(program)); + } + // Full-screen quad + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + -1, -1, 0, 1, + 1, -1, 1, 1, + -1, 1, 0, 0, + -1, 1, 0, 0, + 1, -1, 1, 1, + 1, 1, 1, 0, + ]), gl.STATIC_DRAW); + const posLoc = gl.getAttribLocation(program, 'a_position'); + const uvLoc = gl.getAttribLocation(program, 'a_uv'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(uvLoc); + gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8); + } catch (err) { + console.warn('[impeccable] shader setup failed:', err); + canvas.remove(); + return; + } + + // Upload the screenshot as a texture + let bitmap; + try { + bitmap = await createImageBitmap(blob); + } catch { + // Safari fallback: go via a regular Image + const imgUrl = URL.createObjectURL(blob); + const img = new Image(); + img.src = imgUrl; + await new Promise((r, rej) => { img.onload = r; img.onerror = rej; }); + bitmap = img; + URL.revokeObjectURL(imgUrl); + } + texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + if (bitmap.close) bitmap.close(); + + const uTime = gl.getUniformLocation(program, 'u_time'); + const uRes = gl.getUniformLocation(program, 'u_resolution'); + const uAccent = gl.getUniformLocation(program, 'u_accent'); + const uTex = gl.getUniformLocation(program, 'u_texture'); + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; + function frame() { + if (!shaderState) return; + const elapsed = (performance.now() - shaderState.startTime) / 1000; + const t = shaderState.reduced ? 0.0 : elapsed; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.useProgram(program); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.uniform1i(uTex, 0); + gl.uniform1f(uTime, t); + gl.uniform2f(uRes, canvas.width, canvas.height); + gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]); + gl.drawArrays(gl.TRIANGLES, 0, 6); + shaderState.rafId = requestAnimationFrame(frame); + } + frame(); } function handleAccept() { @@ -2713,6 +3600,7 @@ function init() { initHighlight(); + initAnnotOverlay(); initBar(); initActionPicker(); initGlobalBar(); diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs index 109aa768e..97163b255 100644 --- a/.gemini/skills/impeccable/scripts/live-server.mjs +++ b/.gemini/skills/impeccable/scripts/live-server.mjs @@ -18,7 +18,6 @@ import { randomUUID } from 'node:crypto'; import { spawn, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; @@ -56,8 +55,13 @@ const state = { pendingEvents: [], // browser events waiting for agent poll pendingPolls: [], // agent poll callbacks waiting for browser events exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots }; +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + function enqueueEvent(event) { if (state.pendingPolls.length > 0) { state.pendingPolls.shift()(event); @@ -134,6 +138,10 @@ function validateEvent(msg) { if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; return null; case 'accept': if (!msg.id) return 'accept: missing id'; @@ -175,6 +183,83 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { return; } + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + // --- Health --- if (p === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -250,10 +335,11 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } const absPath = path.resolve(process.cwd(), filePath); if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } - try { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(fs.readFileSync(absPath, 'utf-8')); - } catch { res.writeHead(404); res.end('File not found'); } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); return; } @@ -411,6 +497,9 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } for (const res of state.sseClients) { try { res.end(); } catch {} } state.sseClients.clear(); for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); @@ -442,12 +531,14 @@ Options: --help Show this help Endpoints: - /live.js Browser script (element picker + variant cycling) - /detect.js Detection overlay (backwards compatible) - /events SSE stream (server→browser) + POST (browser→server) - /poll Long-poll for agent CLI - /source Raw source file reader (no-HMR fallback) - /health Health check`); + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); process.exit(0); } @@ -531,6 +622,12 @@ try { state.token = randomUUID(); const portArg = args.find(a => a.startsWith('--port=')); state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); const { detectScript, liveScript } = loadBrowserScripts(); const liveScriptWithToken = diff --git a/.gemini/skills/impeccable/scripts/modern-screenshot.umd.js b/.gemini/skills/impeccable/scripts/modern-screenshot.umd.js new file mode 100644 index 000000000..a9c5208f6 --- /dev/null +++ b/.gemini/skills/impeccable/scripts/modern-screenshot.umd.js @@ -0,0 +1,14 @@ +(function(y,v){typeof exports=="object"&&typeof module!="undefined"?v(exports):typeof define=="function"&&define.amd?define(["exports"],v):(y=typeof globalThis!="undefined"?globalThis:y||self,v(y.modernScreenshot={}))})(this,function(y){"use strict";var rr=Object.defineProperty,nr=Object.defineProperties;var or=Object.getOwnPropertyDescriptors;var Z=Object.getOwnPropertySymbols;var xe=Object.prototype.hasOwnProperty,Me=Object.prototype.propertyIsEnumerable;var Oe=Math.pow,Le=(y,v,N)=>v in y?rr(y,v,{enumerable:!0,configurable:!0,writable:!0,value:N}):y[v]=N,D=(y,v)=>{for(var N in v||(v={}))xe.call(v,N)&&Le(y,N,v[N]);if(Z)for(var N of Z(v))Me.call(v,N)&&Le(y,N,v[N]);return y},M=(y,v)=>nr(y,or(v));var je=(y,v)=>{var N={};for(var R in y)xe.call(y,R)&&v.indexOf(R)<0&&(N[R]=y[R]);if(y!=null&&Z)for(var R of Z(y))v.indexOf(R)<0&&Me.call(y,R)&&(N[R]=y[R]);return N};var C=(y,v,N)=>new Promise((R,O)=>{var X=P=>{try{q(N.next(P))}catch(W){O(W)}},j=P=>{try{q(N.throw(P))}catch(W){O(W)}},q=P=>P.done?R(P.value):Promise.resolve(P.value).then(X,j);q((N=N.apply(y,v)).next())});var Be;function v(e,t){return e[13]=1,e[14]=t>>8,e[15]=t&255,e[16]=t>>8,e[17]=t&255,e}const N=112,R=72,O=89,X=115;let j;function q(){const e=new Int32Array(256);for(let t=0;t<256;t++){let r=t;for(let n=0;n<8;n++)r=r&1?3988292384^r>>>1:r>>>1;e[t]=r}return e}function P(e){let t=-1;j||(j=q());for(let r=0;r>>8;return t^-1}function W(e){const t=e.length-1;for(let r=t;r>=4;r--)if(e[r-4]===9&&e[r-3]===N&&e[r-2]===R&&e[r-1]===O&&e[r]===X)return r-3;return 0}function ae(e,t,r=!1){const n=new Uint8Array(13);t*=39.3701,n[0]=N,n[1]=R,n[2]=O,n[3]=X,n[4]=t>>>24,n[5]=t>>>16,n[6]=t>>>8,n[7]=t&255,n[8]=n[4],n[9]=n[5],n[10]=n[6],n[11]=n[7],n[12]=1;const i=P(n),a=new Uint8Array(4);if(a[0]=i>>>24,a[1]=i>>>16,a[2]=i>>>8,a[3]=i&255,r){const s=W(e);return e.set(n,s),e.set(a,s+13),e}else{const s=new Uint8Array(4);s[0]=0,s[1]=0,s[2]=0,s[3]=9;const o=new Uint8Array(54);return o.set(e,0),o.set(s,33),o.set(n,37),o.set(a,50),o}}const qe="AAlwSFlz",We="AAAJcEhZ",He="AAAACXBI";function Ve(e){let t=e.indexOf(qe);return t===-1&&(t=e.indexOf(We)),t===-1&&(t=e.indexOf(He)),t}const se="[modern-screenshot]",U=typeof window!="undefined",ze=U&&"Worker"in window,ie=U&&"atob"in window,Xe=U&&"btoa"in window,ee=U?(Be=window.navigator)==null?void 0:Be.userAgent:"",le=ee.includes("Chrome"),G=ee.includes("AppleWebKit")&&!le,te=ee.includes("Firefox"),Ge=e=>e&&"__CONTEXT__"in e,Ye=e=>e.constructor.name==="CSSFontFaceRule",Je=e=>e.constructor.name==="CSSImportRule",Ke=e=>e.constructor.name==="CSSLayerBlockRule",I=e=>e.nodeType===1,H=e=>typeof e.className=="object",ce=e=>e.tagName==="image",Qe=e=>e.tagName==="use",V=e=>I(e)&&typeof e.style!="undefined"&&!H(e),Ze=e=>e.nodeType===8,et=e=>e.nodeType===3,$=e=>e.tagName==="IMG",Y=e=>e.tagName==="VIDEO",tt=e=>e.tagName==="CANVAS",rt=e=>e.tagName==="TEXTAREA",nt=e=>e.tagName==="INPUT",ot=e=>e.tagName==="STYLE",at=e=>e.tagName==="SCRIPT",st=e=>e.tagName==="SELECT",it=e=>e.tagName==="SLOT",lt=e=>e.tagName==="IFRAME",ct=(...e)=>console.warn(se,...e);function ut(e){var r;const t=(r=e==null?void 0:e.createElement)==null?void 0:r.call(e,"canvas");return t&&(t.height=t.width=1),!!t&&"toDataURL"in t&&!!t.toDataURL("image/webp").includes("image/webp")}const re=e=>e.startsWith("data:");function ue(e,t){if(e.match(/^[a-z]+:\/\//i))return e;if(U&&e.match(/^\/\//))return window.location.protocol+e;if(e.match(/^[a-z]+:/i)||!U)return e;const r=J().implementation.createHTMLDocument(),n=r.createElement("base"),i=r.createElement("a");return r.head.appendChild(n),r.body.appendChild(i),t&&(n.href=t),i.href=e,i.href}function J(e){var t;return(t=e&&I(e)?e==null?void 0:e.ownerDocument:e)!=null?t:window.document}const K="http://www.w3.org/2000/svg";function fe(e,t,r){const n=J(r).createElementNS(K,"svg");return n.setAttributeNS(null,"width",e.toString()),n.setAttributeNS(null,"height",t.toString()),n.setAttributeNS(null,"viewBox",`0 0 ${e} ${t}`),n}function de(e,t){let r=new XMLSerializer().serializeToString(e);return t&&(r=r.replace(/[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu,"")),`data:image/svg+xml;charset=utf-8,${encodeURIComponent(r)}`}function ft(e,t="image/png",r=1){return C(this,null,function*(){try{return yield new Promise((n,i)=>{e.toBlob(a=>{a?n(a):i(new Error("Blob is null"))},t,r)})}catch(n){if(ie)return dt(e.toDataURL(t,r));throw n}})}function dt(e){var o,c;const[t,r]=e.split(","),n=(c=(o=t.match(/data:(.+);/))==null?void 0:o[1])!=null?c:void 0,i=window.atob(r),a=i.length,s=new Uint8Array(a);for(let u=0;u{const i=new FileReader;i.onload=()=>r(i.result),i.onerror=()=>n(i.error),i.onabort=()=>n(new Error(`Failed read blob to ${t}`)),t==="dataUrl"?i.readAsDataURL(e):t==="arrayBuffer"&&i.readAsArrayBuffer(e)})}const gt=e=>ge(e,"dataUrl"),mt=e=>ge(e,"arrayBuffer");function _(e,t){const r=J(t).createElement("img");return r.decoding="sync",r.loading="eager",r.src=e,r}function L(e,t){return new Promise(r=>{const{timeout:n,ownerDocument:i,onError:a,onWarn:s}=t!=null?t:{},o=typeof e=="string"?_(e,J(i)):e;let c=null,u=null;function l(){r(o),c&&clearTimeout(c),u==null||u()}if(n&&(c=setTimeout(l,n)),Y(o)){const d=o.currentSrc||o.src;if(!d)return o.poster?L(o.poster,t).then(r):l();if(o.readyState>=2)return l();const m=l,f=h=>{s==null||s("Failed video load",d,h),a==null||a(h),l()};u=()=>{o.removeEventListener("loadeddata",m),o.removeEventListener("error",f)},o.addEventListener("loadeddata",m,{once:!0}),o.addEventListener("error",f,{once:!0})}else{const d=ce(o)?o.href.baseVal:o.currentSrc||o.src;if(!d)return l();const m=()=>C(this,null,function*(){if($(o)&&"decode"in o)try{yield o.decode()}catch(h){s==null||s("Failed to decode image, trying to render anyway",o.dataset.originalSrc||d,h)}l()}),f=h=>{s==null||s("Failed image load",o.dataset.originalSrc||d,h),l()};if($(o)&&o.complete)return m();u=()=>{o.removeEventListener("load",m),o.removeEventListener("error",f)},o.addEventListener("load",m,{once:!0}),o.addEventListener("error",f,{once:!0})}})}function me(e,t){return C(this,null,function*(){V(e)&&($(e)||Y(e)?yield L(e,t):yield Promise.all(["img","video"].flatMap(r=>Array.from(e.querySelectorAll(r)).map(n=>L(n,t)))))})}const he=function(){let t=0;const r=()=>`0000${(Math.random()*Oe(36,4)<<0).toString(36)}`.slice(-4);return()=>(t+=1,`u${r()}${t}`)}();function we(e){return e==null?void 0:e.split(",").map(t=>t.trim().replace(/"|'/g,"").toLowerCase()).filter(Boolean)}let pe=0;function ht(e){const t=`${se}[#${pe}]`;return pe++,{time:r=>e&&console.time(`${t} ${r}`),timeEnd:r=>e&&console.timeEnd(`${t} ${r}`),warn:(...r)=>e&&ct(...r)}}function wt(e){return{cache:e?"no-cache":"force-cache"}}function k(e,t){return C(this,null,function*(){return Ge(e)?e:ye(e,M(D({},t),{autoDestruct:!0}))})}function ye(e,t){return C(this,null,function*(){var f,h,g,p,E;const{scale:r=1,workerUrl:n,workerNumber:i=1}=t||{},a=!!(t!=null&&t.debug),s=(f=t==null?void 0:t.features)!=null?f:!0,o=(h=e.ownerDocument)!=null?h:U?window.document:void 0,c=(p=(g=e.ownerDocument)==null?void 0:g.defaultView)!=null?p:U?window:void 0,u=new Map,l=M(D({width:0,height:0,quality:1,type:"image/png",scale:r,backgroundColor:null,style:null,filter:null,maximumCanvasSize:0,timeout:3e4,progress:null,debug:a,fetch:D({requestInit:wt((E=t==null?void 0:t.fetch)==null?void 0:E.bypassingCache),placeholderImage:"data:image/png;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",bypassingCache:!1},t==null?void 0:t.fetch),fetchFn:null,font:{},drawImageInterval:100,workerUrl:null,workerNumber:i,onCloneEachNode:null,onCloneNode:null,onEmbedNode:null,onCreateForeignObjectSvg:null,includeStyleProperties:null,autoDestruct:!1},t),{__CONTEXT__:!0,log:ht(a),node:e,ownerDocument:o,ownerWindow:c,dpi:r===1?null:96*r,svgStyleElement:be(o),svgDefsElement:o==null?void 0:o.createElementNS(K,"defs"),svgStyles:new Map,defaultComputedStyles:new Map,workers:[...Array.from({length:ze&&n&&i?i:0})].map(()=>{try{const b=new Worker(n);return b.onmessage=w=>C(this,null,function*(){var A,F,B,$e;const{url:S,result:T}=w.data;T?(F=(A=u.get(S))==null?void 0:A.resolve)==null||F.call(A,T):($e=(B=u.get(S))==null?void 0:B.reject)==null||$e.call(B,new Error(`Error receiving message from worker: ${S}`))}),b.onmessageerror=w=>{var T,A;const{url:S}=w.data;(A=(T=u.get(S))==null?void 0:T.reject)==null||A.call(T,new Error(`Error receiving message from worker: ${S}`))},b}catch(b){return l.log.warn("Failed to new Worker",b),null}}).filter(Boolean),fontFamilies:new Map,fontCssTexts:new Map,acceptOfImage:`${[ut(o)&&"image/webp","image/svg+xml","image/*","*/*"].filter(Boolean).join(",")};q=0.8`,requests:u,drawImageCount:0,tasks:[],features:s,isEnable:b=>{var w,S;return b==="restoreScrollPosition"?typeof s=="boolean"?!1:(w=s[b])!=null?w:!1:typeof s=="boolean"?s:(S=s[b])!=null?S:!0},shadowRoots:[]});l.log.time("wait until load"),yield me(e,{timeout:l.timeout,onWarn:l.log.warn}),l.log.timeEnd("wait until load");const{width:d,height:m}=pt(e,l);return l.width=d,l.height=m,l})}function be(e){if(!e)return;const t=e.createElement("style"),r=t.ownerDocument.createTextNode(` +.______background-clip--text { + background-clip: text; + -webkit-background-clip: text; +} +`);return t.appendChild(r),t}function pt(e,t){let{width:r,height:n}=t;if(I(e)&&(!r||!n)){const i=e.getBoundingClientRect();r=r||i.width||Number(e.getAttribute("width"))||0,n=n||i.height||Number(e.getAttribute("height"))||0}return{width:r,height:n}}function yt(e,t){return C(this,null,function*(){const{log:r,timeout:n,drawImageCount:i,drawImageInterval:a}=t;r.time("image to canvas");const s=yield L(e,{timeout:n,onWarn:t.log.warn}),{canvas:o,context2d:c}=bt(e.ownerDocument,t),u=()=>{try{c==null||c.drawImage(s,0,0,o.width,o.height)}catch(l){t.log.warn("Failed to drawImage",l)}};if(u(),t.isEnable("fixSvgXmlDecode"))for(let l=0;l{setTimeout(()=>{c==null||c.clearRect(0,0,o.width,o.height),u(),d()},l+a)});return t.drawImageCount=0,r.timeEnd("image to canvas"),o})}function bt(e,t){const{width:r,height:n,scale:i,backgroundColor:a,maximumCanvasSize:s}=t,o=e.createElement("canvas");o.width=Math.floor(r*i),o.height=Math.floor(n*i),o.style.width=`${r}px`,o.style.height=`${n}px`,s&&(o.width>s||o.height>s)&&(o.width>s&&o.height>s?o.width>o.height?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s):o.width>s?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s));const c=o.getContext("2d");return c&&a&&(c.fillStyle=a,c.fillRect(0,0,o.width,o.height)),{canvas:o,context2d:c}}function Se(e,t){if(e.ownerDocument)try{const a=e.toDataURL();if(a!=="data:,")return _(a,e.ownerDocument)}catch(a){t.log.warn("Failed to clone canvas",a)}const r=e.cloneNode(!1),n=e.getContext("2d"),i=r.getContext("2d");try{return n&&i&&i.putImageData(n.getImageData(0,0,e.width,e.height),0,0),r}catch(a){t.log.warn("Failed to clone canvas",a)}return r}function St(e,t){var r;try{if((r=e==null?void 0:e.contentDocument)!=null&&r.documentElement)return ne(e.contentDocument.documentElement,t)}catch(n){t.log.warn("Failed to clone iframe",n)}return e.cloneNode(!1)}function Et(e){const t=e.cloneNode(!1);return e.currentSrc&&e.currentSrc!==e.src&&(t.src=e.currentSrc,t.srcset=""),t.loading==="lazy"&&(t.loading="eager"),t}function Ct(e,t){return C(this,null,function*(){if(e.ownerDocument&&!e.currentSrc&&e.poster)return _(e.poster,e.ownerDocument);const r=e.cloneNode(!1);r.crossOrigin="anonymous",e.currentSrc&&e.currentSrc!==e.src&&(r.src=e.currentSrc);const n=r.ownerDocument;if(n){let i=!0;if(yield L(r,{onError:()=>i=!1,onWarn:t.log.warn}),!i)return e.poster?_(e.poster,e.ownerDocument):r;r.currentTime=e.currentTime,yield new Promise(s=>{r.addEventListener("seeked",s,{once:!0})});const a=n.createElement("canvas");a.width=e.offsetWidth,a.height=e.offsetHeight;try{const s=a.getContext("2d");s&&s.drawImage(r,0,0,a.width,a.height)}catch(s){return t.log.warn("Failed to clone video",s),e.poster?_(e.poster,e.ownerDocument):r}return Se(a,t)}return r})}function Tt(e,t){return tt(e)?Se(e,t):lt(e)?St(e,t):$(e)?Et(e):Y(e)?Ct(e,t):e.cloneNode(!1)}function vt(e){let t=e.sandbox;if(!t){const{ownerDocument:r}=e;try{r&&(t=r.createElement("iframe"),t.id=`__SANDBOX__${he()}`,t.width="0",t.height="0",t.style.visibility="hidden",t.style.position="fixed",r.body.appendChild(t),t.srcdoc='',e.sandbox=t)}catch(n){e.log.warn("Failed to getSandBox",n)}}return t}const At=["width","height","-webkit-text-fill-color"],Nt=["stroke","fill"];function Ee(e,t,r){const{defaultComputedStyles:n}=r,i=e.nodeName.toLowerCase(),a=H(e)&&i!=="svg",s=a?Nt.map(g=>[g,e.getAttribute(g)]).filter(([,g])=>g!==null):[],o=[a&&"svg",i,s.map((g,p)=>`${g}=${p}`).join(","),t].filter(Boolean).join(":");if(n.has(o))return n.get(o);const c=vt(r),u=c==null?void 0:c.contentWindow;if(!u)return new Map;const l=u==null?void 0:u.document;let d,m;a?(d=l.createElementNS(K,"svg"),m=d.ownerDocument.createElementNS(d.namespaceURI,i),s.forEach(([g,p])=>{m.setAttributeNS(null,g,p)}),d.appendChild(m)):d=m=l.createElement(i),m.textContent=" ",l.body.appendChild(d);const f=u.getComputedStyle(m,t),h=new Map;for(let g=f.length,p=0;pn.set(d,l));function s(c){const u=e.getPropertyValue(c),l=e.getPropertyPriority(c),d=c.lastIndexOf("-"),m=d>-1?c.substring(0,d):void 0;if(m){let f=a.get(m);f||(f=new Map,a.set(m,f)),f.set(c,[u,l])}t.get(c)===u&&!l||(m?i.push(m):n.set(c,[u,l]))}return n}function Rt(e,t,r,n){var d,m,f,h;const{ownerWindow:i,includeStyleProperties:a,currentParentNodeStyle:s}=n,o=t.style,c=i.getComputedStyle(e),u=Ee(e,null,n);s==null||s.forEach((g,p)=>{u.delete(p)});const l=Ce(c,u,a);l.delete("transition-property"),l.delete("all"),l.delete("d"),l.delete("content"),r&&(l.delete("position"),l.delete("margin-top"),l.delete("margin-right"),l.delete("margin-bottom"),l.delete("margin-left"),l.delete("margin-block-start"),l.delete("margin-block-end"),l.delete("margin-inline-start"),l.delete("margin-inline-end"),l.set("box-sizing",["border-box",""])),((d=l.get("background-clip"))==null?void 0:d[0])==="text"&&t.classList.add("______background-clip--text"),le&&(l.has("font-kerning")||l.set("font-kerning",["normal",""]),(((m=l.get("overflow-x"))==null?void 0:m[0])==="hidden"||((f=l.get("overflow-y"))==null?void 0:f[0])==="hidden")&&((h=l.get("text-overflow"))==null?void 0:h[0])==="ellipsis"&&e.scrollWidth===e.clientWidth&&l.set("text-overflow",["clip",""]));for(let g=o.length,p=0;p{o.setProperty(E,g,p)}),l}function It(e,t){(rt(e)||nt(e)||st(e))&&t.setAttribute("value",e.value)}const kt=["::before","::after"],Dt=["::-webkit-scrollbar","::-webkit-scrollbar-button","::-webkit-scrollbar-thumb","::-webkit-scrollbar-track","::-webkit-scrollbar-track-piece","::-webkit-scrollbar-corner","::-webkit-resizer"];function Pt(e,t,r,n,i){const{ownerWindow:a,svgStyleElement:s,svgStyles:o,currentNodeStyle:c}=n;if(!s||!a)return;function u(l){var w;const d=a.getComputedStyle(e,l);let m=d.getPropertyValue("content");if(!m||m==="none")return;i==null||i(m),m=m.replace(/(')|(")|(counter\(.+\))/g,"");const f=[he()],h=Ee(e,l,n);c==null||c.forEach((S,T)=>{h.delete(T)});const g=Ce(d,h,n.includeStyleProperties);g.delete("content"),g.delete("-webkit-locale"),((w=g.get("background-clip"))==null?void 0:w[0])==="text"&&t.classList.add("______background-clip--text");const p=[`content: '${m}';`];if(g.forEach(([S,T],A)=>{p.push(`${A}: ${S}${T?" !important":""};`)}),p.length===1)return;try{t.className=[t.className,...f].join(" ")}catch(S){n.log.warn("Failed to copyPseudoClass",S);return}const E=p.join(` + `);let b=o.get(E);b||(b=[],o.set(E,b)),b.push(`.${f[0]}${l}`)}kt.forEach(u),r&&Dt.forEach(u)}const Te=new Set(["symbol"]);function ve(e,t,r,n,i){return C(this,null,function*(){if(I(r)&&(ot(r)||at(r))||n.filter&&!n.filter(r))return;Te.has(t.nodeName)||Te.has(r.nodeName)?n.currentParentNodeStyle=void 0:n.currentParentNodeStyle=n.currentNodeStyle;const a=yield ne(r,n,!1,i);n.isEnable("restoreScrollPosition")&&Ut(e,a),t.appendChild(a)})}function Ae(e,t,r,n){return C(this,null,function*(){var a;let i=e.firstChild;I(e)&&e.shadowRoot&&(i=(a=e.shadowRoot)==null?void 0:a.firstChild,r.shadowRoots.push(e.shadowRoot));for(let s=i;s;s=s.nextSibling)if(!Ze(s))if(I(s)&&it(s)&&typeof s.assignedNodes=="function"){const o=s.assignedNodes();for(let c=0;ce.clientHeight||e.scrollWidth>e.clientWidth)}const p=(d=h.get("text-transform"))==null?void 0:d[0],E=we((m=h.get("font-family"))==null?void 0:m[0]),b=E?w=>{p==="uppercase"?w=w.toUpperCase():p==="lowercase"?w=w.toLowerCase():p==="capitalize"&&(w=w[0].toUpperCase()+w.substring(1)),E.forEach(S=>{let T=s.get(S);T||s.set(S,T=new Set),w.split("").forEach(A=>T.add(A))})}:void 0;return Pt(e,f,g,t,b),It(e,f),Y(e)||(yield Ae(e,f,t,b)),yield o==null?void 0:o(f),f}const c=e.cloneNode(!1);return yield Ae(e,c,t),yield o==null?void 0:o(c),c})}function Ne(e){if(e.ownerDocument=void 0,e.ownerWindow=void 0,e.svgStyleElement=void 0,e.svgDefsElement=void 0,e.svgStyles.clear(),e.defaultComputedStyles.clear(),e.sandbox){try{e.sandbox.remove()}catch(t){e.log.warn("Failed to destroyContext",t)}e.sandbox=void 0}e.workers=[],e.fontFamilies.clear(),e.fontCssTexts.clear(),e.requests.clear(),e.tasks=[],e.shadowRoots=[]}function Bt(e){const o=e,{url:t,timeout:r,responseType:n}=o,i=je(o,["url","timeout","responseType"]),a=new AbortController,s=r?setTimeout(()=>a.abort(),r):void 0;return fetch(t,D({signal:a.signal},i)).then(c=>{if(!c.ok)throw new Error("Failed fetch, not 2xx response",{cause:c});switch(n){case"arrayBuffer":return c.arrayBuffer();case"dataUrl":return c.blob().then(gt);case"text":default:return c.text()}}).finally(()=>clearTimeout(s))}function z(e,t){const{url:r,requestType:n="text",responseType:i="text",imageDom:a}=t;let s=r;const{timeout:o,acceptOfImage:c,requests:u,fetchFn:l,fetch:{requestInit:d,bypassingCache:m,placeholderImage:f},font:h,workers:g,fontFamilies:p}=e;n==="image"&&(G||te)&&e.drawImageCount++;let E=u.get(r);if(!E){m&&m instanceof RegExp&&m.test(s)&&(s+=(/\?/.test(s)?"&":"?")+new Date().getTime());const b=n.startsWith("font")&&h&&h.minify,w=new Set;b&&n.split(";")[1].split(",").forEach(F=>{p.has(F)&&p.get(F).forEach(B=>w.add(B))});const S=b&&w.size,T=D({url:s,timeout:o,responseType:S?"arrayBuffer":i,headers:n==="image"?{accept:c}:void 0},d);E={type:n,resolve:void 0,reject:void 0,response:null},E.response=C(this,null,function*(){if(l&&n==="image"){const A=yield l(r);if(A)return A}return!G&&r.startsWith("http")&&g.length?new Promise((A,F)=>{g[u.size&g.length-1].postMessage(D({rawUrl:r},T)),E.resolve=A,E.reject=F}):Bt(T)}).catch(A=>{if(u.delete(r),n==="image"&&f)return e.log.warn("Failed to fetch image base64, trying to use placeholder image",s),typeof f=="string"?f:f(a);throw A}),u.set(r,E)}return E.response}function Re(e,t,r,n){return C(this,null,function*(){if(!Ie(e))return e;for(const[i,a]of $t(e,t))try{const s=yield z(r,{url:a,requestType:n?"image":"text",responseType:"dataUrl"});e=e.replace(Lt(i),`$1${s}$3`)}catch(s){r.log.warn("Failed to fetch css data url",i,s)}return e})}function Ie(e){return/url\((['"]?)([^'"]+?)\1\)/.test(e)}const ke=/url\((['"]?)([^'"]+?)\1\)/g;function $t(e,t){const r=[];return e.replace(ke,(n,i,a)=>(r.push([a,ue(a,t)]),n)),r.filter(([n])=>!re(n))}function Lt(e){const t=e.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${t})(['"]?\\))`,"g")}const xt=["background-image","border-image-source","-webkit-border-image","-webkit-mask-image","list-style-image"];function Mt(e,t){return xt.map(r=>{const n=e.getPropertyValue(r);return!n||n==="none"?null:((G||te)&&t.drawImageCount++,Re(n,null,t,!0).then(i=>{!i||n===i||e.setProperty(r,i,e.getPropertyPriority(r))}))}).filter(Boolean)}function Ot(e,t){if($(e)){const r=e.currentSrc||e.src;if(!re(r))return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.srcset="",e.dataset.originalSrc=r,e.src=n||"")})];(G||te)&&t.drawImageCount++}else if(H(e)&&!re(e.href.baseVal)){const r=e.href.baseVal;return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.dataset.originalSrc=r,e.href.baseVal=n||"")})]}return[]}function jt(e,t){var o;const{ownerDocument:r,svgDefsElement:n}=t,i=(o=e.getAttribute("href"))!=null?o:e.getAttribute("xlink:href");if(!i)return[];const[a,s]=i.split("#");if(s){const c=`#${s}`,u=t.shadowRoots.reduce((l,d)=>l!=null?l:d.querySelector(`svg ${c}`),r==null?void 0:r.querySelector(`svg ${c}`));if(a&&e.setAttribute("href",c),n!=null&&n.querySelector(c))return[];if(u)return n==null||n.appendChild(u.cloneNode(!0)),[];if(a)return[z(t,{url:a,responseType:"text"}).then(l=>{n==null||n.insertAdjacentHTML("beforeend",l)})]}return[]}function De(e,t){const{tasks:r}=t;I(e)&&(($(e)||ce(e))&&r.push(...Ot(e,t)),Qe(e)&&r.push(...jt(e,t))),V(e)&&r.push(...Mt(e.style,t)),e.childNodes.forEach(n=>{De(n,t)})}function qt(e,t){return C(this,null,function*(){const{ownerDocument:r,svgStyleElement:n,fontFamilies:i,fontCssTexts:a,tasks:s,font:o}=t;if(!(!r||!n||!i.size))if(o&&o.cssText){const c=Ue(o.cssText,t);n.appendChild(r.createTextNode(`${c} +`))}else{const c=Array.from(r.styleSheets).filter(f=>{try{return"cssRules"in f&&!!f.cssRules.length}catch(h){return t.log.warn(`Error while reading CSS rules from ${f.href}`,h),!1}}),u=r.implementation.createHTMLDocument(""),l=u.createElement("style");u.head.appendChild(l);const d=l.sheet;yield Promise.all(c.flatMap(f=>Array.from(f.cssRules).map(h=>C(this,null,function*(){if(Je(h)){const g=h.href;let p="";try{p=yield z(t,{url:g,requestType:"text",responseType:"text"})}catch(b){t.log.warn(`Error fetch remote css import from ${g}`,b)}const E=p.replace(ke,(b,w,S)=>b.replace(S,ue(S,g)));for(const b of Ht(E))try{d.insertRule(b,d.cssRules.length)}catch(w){t.log.warn("Error inserting rule from remote css import",{rule:b,error:w})}}})))),d.cssRules.length&&c.push(d);const m=[];c.forEach(f=>{oe(f.cssRules,m)}),m.filter(f=>{var h;return Ye(f)&&Ie(f.style.getPropertyValue("src"))&&((h=we(f.style.getPropertyValue("font-family")))==null?void 0:h.some(g=>i.has(g)))}).forEach(f=>{const h=f,g=a.get(h.cssText);g?n.appendChild(r.createTextNode(`${g} +`)):s.push(Re(h.cssText,h.parentStyleSheet?h.parentStyleSheet.href:null,t).then(p=>{p=Ue(p,t),a.set(h.cssText,p),n.appendChild(r.createTextNode(`${p} +`))}))})}})}const Wt=/(\/\*[\s\S]*?\*\/)/g,Pe=/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi;function Ht(e){if(e==null)return[];const t=[];let r=e.replace(Wt,"");for(;;){const a=Pe.exec(r);if(!a)break;t.push(a[0])}r=r.replace(Pe,"");const n=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,i=new RegExp("((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})","gi");for(;;){let a=n.exec(r);if(a)i.lastIndex=n.lastIndex;else if(a=i.exec(r),a)n.lastIndex=i.lastIndex;else break;t.push(a[0])}return t}const Vt=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,zt=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Ue(e,t){const{font:r}=t,n=r?r==null?void 0:r.preferredFormat:void 0;return n?e.replace(zt,i=>{for(;;){const[a,,s]=Vt.exec(i)||[];if(!s)return"";if(s===n)return`src: ${a};`}}):e}function oe(e,t=[]){for(const r of Array.from(e))Ke(r)?t.push(...oe(r.cssRules)):"cssRules"in r?oe(r.cssRules,t):t.push(r);return t}const Xt=/\bx?link:?href\s*=\s*["'](?!data:)[^"']+["']/i;function Gt(e){return Xt.test(e.innerHTML)}function _e(e,t){return C(this,null,function*(){const r=yield k(e,t);if(I(r.node)&&H(r.node)&&!Gt(r.node))return r.node;const{ownerDocument:n,log:i,tasks:a,svgStyleElement:s,svgDefsElement:o,svgStyles:c,font:u,progress:l,autoDestruct:d,onCloneNode:m,onEmbedNode:f,onCreateForeignObjectSvg:h}=r;i.time("clone node");const g=yield ne(r.node,r,!0);if(s&&n){let S="";c.forEach((T,A)=>{S+=`${T.join(`, +`)} { + ${A} +} +`}),s.appendChild(n.createTextNode(S))}i.timeEnd("clone node"),yield m==null?void 0:m(g),u!==!1&&I(g)&&(i.time("embed web font"),yield qt(g,r),i.timeEnd("embed web font")),i.time("embed node"),De(g,r);const p=a.length;let E=0;const b=()=>C(this,null,function*(){for(;;){const S=a.pop();if(!S)break;try{yield S}catch(T){r.log.warn("Failed to run task",T)}l==null||l(++E,p)}});l==null||l(E,p),yield Promise.all([...Array.from({length:4})].map(b)),i.timeEnd("embed node"),yield f==null?void 0:f(g);const w=Yt(g,r);return o&&w.insertBefore(o,w.children[0]),s&&w.insertBefore(s,w.children[0]),d&&Ne(r),yield h==null?void 0:h(w),w})}function Yt(e,t){const{width:r,height:n}=t,i=fe(r,n,e.ownerDocument),a=i.ownerDocument.createElementNS(i.namespaceURI,"foreignObject");return a.setAttributeNS(null,"x","0%"),a.setAttributeNS(null,"y","0%"),a.setAttributeNS(null,"width","100%"),a.setAttributeNS(null,"height","100%"),a.append(e),i.appendChild(a),i}function Q(e,t){return C(this,null,function*(){var s;const r=yield k(e,t),n=yield _e(r),i=de(n,r.isEnable("removeControlCharacter"));r.autoDestruct||(r.svgStyleElement=be(r.ownerDocument),r.svgDefsElement=(s=r.ownerDocument)==null?void 0:s.createElementNS(K,"defs"),r.svgStyles.clear());const a=_(i,n.ownerDocument);return yield yt(a,r)})}function Jt(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,type:i,quality:a,dpi:s}=r,o=yield Q(r);n.time("canvas to blob");const c=yield ft(o,i,a);if(["image/png","image/jpeg"].includes(i)&&s){const u=yield mt(c.slice(0,33));let l=new Uint8Array(u);return i==="image/png"?l=ae(l,s):i==="image/jpeg"&&(l=v(l,s)),n.timeEnd("canvas to blob"),new Blob([l,c.slice(33)],{type:i})}return n.timeEnd("canvas to blob"),c})}function x(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,quality:i,type:a,dpi:s}=r,o=yield Q(r);n.time("canvas to data url");let c=o.toDataURL(a,i);if(["image/png","image/jpeg"].includes(a)&&s&&ie&&Xe){const[u,l]=c.split(",");let d=0,m=!1;if(a==="image/png"){const w=Ve(l);w>=0?(d=Math.ceil((w+28)/3)*4,m=!0):d=33/3*4}else a==="image/jpeg"&&(d=18/3*4);const f=l.substring(0,d),h=l.substring(d),g=window.atob(f),p=new Uint8Array(g.length);for(let w=0;w mirroring selectedElement's bounding rect. Click (no + // drag) drops a comment pin; drag paints a magenta SVG stroke. All coords + // are stored in element-local CSS px so they survive scroll / resize and + // correlate directly with the captured PNG. + // --------------------------------------------------------------------------- + + const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click + const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it + let annotOverlayEl = null; + let annotSvgEl = null; + let annotPinsEl = null; + let annotClearChipEl = null; + let annotState = { comments: [], strokes: [] }; + let annotActive = false; + // `annotPointer` is either: + // { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin + // { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin + let annotPointer = null; + let annotEditing = null; // { idx, input, wrapEl } + let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete + + function initAnnotOverlay() { + annotOverlayEl = document.createElement('div'); + annotOverlayEl.id = PREFIX + '-annot'; + Object.assign(annotOverlayEl.style, { + position: 'fixed', top: '0', left: '0', width: '0', height: '0', + pointerEvents: 'auto', zIndex: Z.highlight + 2, + display: 'none', overflow: 'visible', + cursor: 'crosshair', touchAction: 'none', + }); + + annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + annotSvgEl.id = PREFIX + '-annot-svg'; + Object.assign(annotSvgEl.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', + // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via + // pointer-events=stroke so gaps still fall through to the overlay. + pointerEvents: 'none', overflow: 'visible', + }); + annotOverlayEl.appendChild(annotSvgEl); + + annotPinsEl = document.createElement('div'); + annotPinsEl.id = PREFIX + '-annot-pins'; + Object.assign(annotPinsEl.style, { + position: 'absolute', inset: '0', + pointerEvents: 'none', + }); + annotOverlayEl.appendChild(annotPinsEl); + + annotClearChipEl = document.createElement('div'); + annotClearChipEl.id = PREFIX + '-annot-clear'; + annotClearChipEl.dataset.annotClear = 'true'; + annotClearChipEl.textContent = 'Clear'; + Object.assign(annotClearChipEl.style, { + position: 'absolute', top: '8px', right: '8px', + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '10px', fontWeight: '500', + letterSpacing: '0.08em', textTransform: 'uppercase', + padding: '5px 12px', borderRadius: '999px', + cursor: 'pointer', pointerEvents: 'auto', + display: 'none', userSelect: 'none', + boxShadow: '0 1px 3px rgba(0,0,0,0.2)', + }); + annotOverlayEl.appendChild(annotClearChipEl); + + annotOverlayEl.addEventListener('pointerdown', onAnnotDown); + annotOverlayEl.addEventListener('pointermove', onAnnotMove); + annotOverlayEl.addEventListener('pointerup', onAnnotUp); + annotOverlayEl.addEventListener('pointercancel', onAnnotUp); + document.body.appendChild(annotOverlayEl); + } + + function updateClearChip() { + if (!annotClearChipEl) return; + const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0; + annotClearChipEl.style.display = hasAny ? 'block' : 'none'; + } + + function showAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + annotActive = true; + positionAnnotOverlay(el); + annotOverlayEl.style.display = 'block'; + } + + function hideAnnotOverlay() { + annotActive = false; + if (annotOverlayEl) annotOverlayEl.style.display = 'none'; + // Drop any in-progress edit without touching annotState — clearAnnotations + // (if the caller is exiting configure mode) handles state reset. + annotEditing = null; + } + + function positionAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + const r = el.getBoundingClientRect(); + Object.assign(annotOverlayEl.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height); + } + + function clearAnnotations() { + annotState.comments = []; + annotState.strokes = []; + if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + if (annotPinsEl) annotPinsEl.innerHTML = ''; + annotPointer = null; + annotEditing = null; + annotLastPinClick = { idx: -1, time: 0 }; + updateClearChip(); + } + + // Rebuild the SVG layer. Each stroke gets a wider invisible hit path + // beneath the visible magenta path so clicks register on thin lines. + function redrawStrokes() { + while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + annotState.strokes.forEach((s, idx) => { + const d = pointsToPath(s.points); + const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + hit.setAttribute('d', d); + hit.setAttribute('stroke', 'transparent'); + hit.setAttribute('stroke-width', '16'); + hit.setAttribute('stroke-linecap', 'round'); + hit.setAttribute('stroke-linejoin', 'round'); + hit.setAttribute('fill', 'none'); + hit.setAttribute('pointer-events', 'stroke'); + hit.style.cursor = 'pointer'; + hit.dataset.annotStroke = String(idx); + annotSvgEl.appendChild(hit); + const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + visible.setAttribute('d', d); + visible.setAttribute('stroke', C.brand); + visible.setAttribute('stroke-width', '3'); + visible.setAttribute('stroke-linecap', 'round'); + visible.setAttribute('stroke-linejoin', 'round'); + visible.setAttribute('fill', 'none'); + visible.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(visible); + }); + updateClearChip(); + } + + function localCoords(e) { + const rect = annotOverlayEl.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + function onAnnotDown(e) { + if (!annotActive) return; + + // 1) Clear chip → wipe all annotations + if (e.target.closest?.('[data-annot-clear]')) { + if (annotEditing) annotEditing = null; + clearAnnotations(); + renderAllPins(); + redrawStrokes(); + e.stopPropagation(); e.preventDefault(); + return; + } + + // 2) Stroke hit path → delete that stroke + const strokeHit = e.target.closest?.('[data-annot-stroke]'); + if (strokeHit) { + const idx = parseInt(strokeHit.dataset.annotStroke, 10); + if (Number.isInteger(idx)) { + annotState.strokes.splice(idx, 1); + redrawStrokes(); + } + e.stopPropagation(); e.preventDefault(); + return; + } + + // 3) Pin → drag, edit, or delete-on-double-click + const pinWrap = e.target.closest?.('[data-annot-pin]'); + if (pinWrap) { + const idx = parseInt(pinWrap.dataset.annotPin, 10); + if (!Number.isInteger(idx)) return; + // Double-click (two pointerdowns on the same pin within window) → delete. + const now = Date.now(); + if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) { + if (annotEditing && annotEditing.idx === idx) annotEditing = null; + annotState.comments.splice(idx, 1); + annotLastPinClick = { idx: -1, time: 0 }; + renderAllPins(); + e.stopPropagation(); e.preventDefault(); + return; + } + annotLastPinClick = { idx, time: now }; + // If editing a different pin, commit that edit before starting here. + if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin(); + // If already editing THIS pin and the user clicked the dot, let the + // input keep focus (don't start a drag — the click wasn't meant as one). + if (annotEditing && annotEditing.idx === idx) return; + const p = localCoords(e); + const pin = annotState.comments[idx]; + annotPointer = { + kind: 'pin', idx, + startPointer: p, + startPin: { x: pin.x, y: pin.y }, + moved: false, + }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + return; + } + + // 4) Empty area → commit any open edit, then start new annotation + if (annotEditing) { + finalizeEditingPin(); + e.stopPropagation(); e.preventDefault(); + return; + } + const p = localCoords(e); + annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + } + + function onAnnotMove(e) { + if (!annotActive || !annotPointer) return; + const p = localCoords(e); + + if (annotPointer.kind === 'pin') { + const dx = p.x - annotPointer.startPointer.x; + const dy = p.y - annotPointer.startPointer.y; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + } + const pin = annotState.comments[annotPointer.idx]; + if (!pin) { annotPointer = null; return; } + pin.x = annotPointer.startPin.x + dx; + pin.y = annotPointer.startPin.y + dy; + renderAllPins(); + e.stopPropagation(); + return; + } + + // kind === 'new' + const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + strokeEl.setAttribute('stroke', C.brand); + strokeEl.setAttribute('stroke-width', '3'); + strokeEl.setAttribute('stroke-linecap', 'round'); + strokeEl.setAttribute('stroke-linejoin', 'round'); + strokeEl.setAttribute('fill', 'none'); + strokeEl.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(strokeEl); + annotPointer.strokeEl = strokeEl; + annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]]; + } + annotPointer.strokePoints.push([p.x, p.y]); + annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints)); + e.stopPropagation(); + } + + function onAnnotUp(e) { + if (!annotActive || !annotPointer) return; + + if (annotPointer.kind === 'pin') { + const wasDrag = annotPointer.moved; + const idx = annotPointer.idx; + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + if (wasDrag) { + // A drag is an intentional reposition; a follow-up click shouldn't be + // interpreted as a double-click-to-delete. + annotLastPinClick = { idx: -1, time: 0 }; + } else { + beginEditPin(idx); + } + e.stopPropagation(); + return; + } + + // kind === 'new' + const wasDrag = annotPointer.moved; + if (wasDrag) { + annotState.strokes.push({ points: annotPointer.strokePoints }); + // Swap the temporary preview SVG path for the full render with hit paths. + redrawStrokes(); + } else { + const idx = annotState.comments.length; + annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' }); + renderAllPins(); + beginEditPin(idx); + } + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + e.stopPropagation(); + } + + function pointsToPath(points) { + if (!points || points.length === 0) return ''; + let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1); + for (let i = 1; i < points.length; i++) { + d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1); + } + return d; + } + + function renderAllPins() { + annotPinsEl.innerHTML = ''; + annotState.comments.forEach((c, idx) => { + annotPinsEl.appendChild(buildPinElement(c, idx)); + }); + updateClearChip(); + } + + function buildPinElement(comment, idx) { + const interactive = idx >= 0; + const wrap = document.createElement('div'); + if (interactive) wrap.dataset.annotPin = String(idx); + Object.assign(wrap.style, { + position: 'absolute', + left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px', + pointerEvents: interactive ? 'auto' : 'none', + display: 'flex', alignItems: 'flex-start', gap: '6px', + cursor: interactive ? 'grab' : 'default', + touchAction: 'none', + }); + const dot = document.createElement('div'); + Object.assign(dot.style, { + width: '14px', height: '14px', borderRadius: '50%', + background: C.brand, border: '2px solid ' + C.white, + boxShadow: '0 1px 3px rgba(0,0,0,0.25)', + flexShrink: '0', + }); + wrap.appendChild(dot); + + if (comment.text) { + const bubble = document.createElement('div'); + bubble.textContent = comment.text; + Object.assign(bubble.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + marginTop: '-2px', maxWidth: '220px', + pointerEvents: 'none', whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }); + wrap.appendChild(bubble); + } + return wrap; + } + + function beginEditPin(idx) { + const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]'); + if (!wrapEl) return; + // Strip any existing bubble (but keep the dot) + wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove()); + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Note…'; + Object.assign(input.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + border: '1px solid ' + C.brand, + outline: 'none', marginTop: '-2px', + width: '220px', pointerEvents: 'auto', + }); + const originalText = annotState.comments[idx].text || ''; + input.value = originalText; + wrapEl.appendChild(input); + annotEditing = { idx, input, wrapEl, originalText }; + input.addEventListener('keydown', onAnnotInputKey, true); + input.addEventListener('blur', () => { + // Fires on both focus-loss and programmatic blur; commit unless we + // already handled it. + if (annotEditing && annotEditing.input === input) finalizeEditingPin(); + }); + // Stop clicks/pointerdowns inside the input from bubbling to the overlay + ['pointerdown', 'click'].forEach(ev => { + input.addEventListener(ev, e => e.stopPropagation()); + }); + setTimeout(() => input.focus(), 0); + } + + function onAnnotInputKey(e) { + if (e.key === 'Enter') { + e.preventDefault(); e.stopPropagation(); + finalizeEditingPin(); + } else if (e.key === 'Escape') { + e.preventDefault(); e.stopPropagation(); + cancelEditingPin(); + } else { + // Keep arrows / backspace from hitting global handlers + e.stopPropagation(); + } + } + + function finalizeEditingPin() { + if (!annotEditing) return; + const { idx, input } = annotEditing; + const text = input.value.trim(); + annotEditing = null; + if (text) annotState.comments[idx].text = text; + else annotState.comments.splice(idx, 1); + renderAllPins(); + } + + function cancelEditingPin() { + if (!annotEditing) return; + const { idx, originalText } = annotEditing; + annotEditing = null; + // If the pin had text before this edit, revert to it. If it was a + // just-created empty pin, Escape removes it. + if (originalText) { + annotState.comments[idx].text = originalText; + } else { + annotState.comments.splice(idx, 1); + } + renderAllPins(); + } + + // Build a detached annotation subtree suitable for injection into the clone + // modern-screenshot creates. Coordinates are element-local so this slots + // straight into an element that's been made position:relative. Takes an + // explicit snapshot so it works after annotState has been cleared. + function buildAnnotationsForCapture(rect, snapshot) { + const comments = snapshot ? snapshot.comments : annotState.comments; + const strokes = snapshot ? snapshot.strokes : annotState.strokes; + if (comments.length === 0 && strokes.length === 0) return null; + const wrap = document.createElement('div'); + Object.assign(wrap.style, { + position: 'absolute', top: '0', left: '0', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', overflow: 'visible', + }); + if (strokes.length > 0) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height); + Object.assign(svg.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', overflow: 'visible', + }); + for (const s of strokes) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('stroke', C.brand); + path.setAttribute('stroke-width', '3'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('fill', 'none'); + path.setAttribute('d', pointsToPath(s.points)); + svg.appendChild(path); + } + wrap.appendChild(svg); + } + for (const c of comments) { + // idx=-1 means non-interactive; pointerEvents stay off in the clone + wrap.appendChild(buildPinElement(c, -1)); + } + return wrap; } // --------------------------------------------------------------------------- @@ -416,8 +901,10 @@ fontSize: '11px', color: C.ash, whiteSpace: 'nowrap', marginLeft: 'auto', }); + // Variants currently arrive atomically in a single file edit, so a + // per-variant counter would lie. Say what's true. status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + (arrivedVariants + 1) + ' of ' + expectedVariants + '...' + ? 'Generating ' + expectedVariants + ' variants...' : 'Done'; row.appendChild(status); @@ -838,6 +1325,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + hideShaderOverlay(); updateBarContent('cycling'); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -860,6 +1348,10 @@ positionBar(); showHighlight(selectedElement); } + if (annotActive) positionAnnotOverlay(selectedElement); + // Shader overlay (via debug P toggle or generation) is repositioned + // by its own branch below; debug no longer has a separate overlay. + if (shaderState) positionShaderOverlay(); scrollRaf = requestAnimationFrame(tick); } scrollRaf = requestAnimationFrame(tick); @@ -937,6 +1429,8 @@ } hideBar(); hideHighlight(); + hideShaderOverlay(); + hideAnnotOverlay(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } clearSession(); @@ -976,6 +1470,8 @@ if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { hideBar(); stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -989,15 +1485,19 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); } function handleKeyDown(e) { + // When the annotation input is focused, let it handle its own keys. + if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); state = 'PICKING'; return; } + if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { hideHighlight(); state = 'IDLE'; return; } @@ -1024,6 +1524,8 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); return; @@ -1035,6 +1537,8 @@ } else { // CONFIGURING: re-select the new element and refresh the bar selectedElement = next; + clearAnnotations(); + showAnnotOverlay(next); showBar('configure'); startScrollTracking(); } @@ -1056,25 +1560,408 @@ const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; + // Commit any pending pin edit BEFORE we snapshot annotations. + if (annotEditing) finalizeEditingPin(); + currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; - sendEvent({ + // Flip to GENERATING immediately so the bar morphs without waiting on + // capture + upload. The event is emitted from captureAndEmit() once the + // screenshot is uploaded (or capture fails — we still emit, just without + // screenshotPath). + const elForCapture = selectedElement; + const captureRect = elForCapture.getBoundingClientRect(); + const snapshot = { + comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), + strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })), + }; + const basePayload = { type: 'generate', id: currentSessionId, action: selectedAction, freeformPrompt: prompt || undefined, count: selectedCount, pageUrl: location.pathname, - element: extractContext(selectedElement), - }); + element: extractContext(elForCapture), + }; + if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments; + if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes; + + // Hide the interactive overlay so it doesn't linger during generation. + hideAnnotOverlay(); + clearAnnotations(); state = 'GENERATING'; showBar('generating'); saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + + captureAndEmit(elForCapture, basePayload, snapshot, captureRect); + } + + // --------------------------------------------------------------------------- + // Screenshot capture + upload + // --------------------------------------------------------------------------- + + let msLoadPromise = null; + function loadModernScreenshot() { + if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot); + if (msLoadPromise) return msLoadPromise; + msLoadPromise = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; + s.onload = () => resolve(window.modernScreenshot); + s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; + document.head.appendChild(s); + }); + return msLoadPromise; + } + + // Collect @font-face rules from every stylesheet on the page. Cross-origin + // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules + // access, so modern-screenshot can't embed them on its own — the resulting + // SVG falls back to system fonts and text re-wraps + renders with different + // weight. We fetch the raw CSS text (CORS-permitted for these providers), + // extract @font-face blocks, inline the referenced font files as base64 + // data URIs (SVGs rasterized via canvas can't fetch external resources, + // so URLs inside the SVG silently fail without this), and pass the result + // to modern-screenshot as font.cssText. + const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i; + const FONT_MIME = { + woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject', + }; + function bufferToBase64(buf) { + const bytes = new Uint8Array(buf); + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); + } + async function inlineFontUrls(cssText) { + const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g; + const urls = new Set(); + let m; + while ((m = urlRe.exec(cssText))) { + if (FONT_EXT_RE.test(m[2])) urls.add(m[2]); + } + const map = new Map(); + await Promise.all([...urls].map(async (url) => { + try { + const res = await fetch(url); + if (!res.ok) return; + const buf = await res.arrayBuffer(); + const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2'; + const mime = FONT_MIME[ext] || 'application/octet-stream'; + map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf)); + } catch { /* skip; fall through to URL */ } + })); + return cssText.replace(urlRe, (orig, q, url) => { + const data = map.get(url); + return data ? 'url(' + q + data + q + ')' : orig; + }); + } + async function collectFontCssText() { + const chunks = []; + const fontFaceRe = /@font-face\s*\{[^}]*\}/g; + for (const sheet of document.styleSheets) { + try { + const rules = sheet.cssRules; + for (const rule of rules) { + if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) { + chunks.push(rule.cssText); + } + } + } catch { + if (!sheet.href) continue; + try { + const res = await fetch(sheet.href); + if (!res.ok) continue; + const text = await res.text(); + let m2; + while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]); + } catch { /* ignore; capture is best-effort */ } + } + } + if (chunks.length === 0) return ''; + return inlineFontUrls(chunks.join('\n')); + } + + // Capture the element (with current annotations baked in) and return a PNG + // Blob. Shared between the Go flow (uploads it to the server) and the + // debug toggle (displays it as an overlay for side-by-side comparison). + async function captureElementToBlob(el, snapshot, rect) { + try { if (document.fonts?.ready) await document.fonts.ready; } catch {} + const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); + let annotNode = null; + let savedPosition = null; + if (hasAnnotations) { + const pos = getComputedStyle(el).position; + if (pos === 'static') { + savedPosition = el.style.position; + el.style.position = 'relative'; + } + annotNode = buildAnnotationsForCapture(rect, snapshot); + el.appendChild(annotNode); + } + try { + const ms = await loadModernScreenshot(); + const fontCssText = await collectFontCssText(); + return await ms.domToBlob(el, { + scale: Math.min(window.devicePixelRatio || 1, 2), + backgroundColor: getComputedStyle(document.body).backgroundColor || '#ffffff', + font: fontCssText ? { cssText: fontCssText } : undefined, + }); + } finally { + if (annotNode) annotNode.remove(); + if (savedPosition !== null) el.style.position = savedPosition; + } + } + + async function captureAndEmit(el, basePayload, snapshot, rect) { + let screenshotPath; + let blob; + try { + blob = await captureElementToBlob(el, snapshot, rect); + } catch (err) { + console.warn('[impeccable] capture failed, proceeding without screenshot:', err); + } + // Light up the shader overlay the moment capture is ready — no reason to + // wait for the upload to complete before the user sees something alive. + if (blob && state === 'GENERATING') { + showShaderOverlay(el, blob, rect); + } + if (blob) { + try { + const uploadRes = await fetch( + 'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) + + '&eventId=' + encodeURIComponent(basePayload.id), + { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob }, + ); + if (uploadRes.ok) { + const { path: p } = await uploadRes.json(); + screenshotPath = p; + } else { + console.warn('[impeccable] annotation upload failed:', uploadRes.status); + } + } catch (err) { + console.warn('[impeccable] annotation upload failed:', err); + } + } + sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); + } + + // --------------------------------------------------------------------------- + // Shader overlay — renders the captured screenshot as a WebGL texture and + // runs an editorial "ink-wash" fragment shader over it during generation. + // A single rolling band sweeps top-to-bottom, desaturating + tinting magenta + // and leaving a soft trail. Makes the wait feel like a letterpress scan + // instead of a dead spinner. + // --------------------------------------------------------------------------- + + const SHADER_VS = `attribute vec2 a_position; +attribute vec2 a_uv; +varying vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_position, 0.0, 1.0); +}`; + + const SHADER_FS = `precision highp float; +uniform sampler2D u_texture; +uniform float u_time; +uniform vec2 u_resolution; +uniform vec3 u_accent; +varying vec2 v_uv; + +// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at +// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean +// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below" +// failure that reversed-edge smoothstep would give). +float bandAt(float d, float leadW, float trailW) { + float above = smoothstep(-leadW, 0.0, d); + float below = 1.0 - smoothstep(0.0, trailW, d); + return above * below; +} + +void main() { + vec2 uv = v_uv; + // Roller sweeps top-to-bottom with small overshoot so each cycle enters + // and exits the element cleanly. + float phase = fract(u_time / 3.4); + float y = phase * 1.25 - 0.12; + float band = bandAt(uv.y - y, 0.05, 0.32); + + // Halftone cell grid (fixed ~10 px pitch). + float cellPx = 10.0; + vec2 gridUv = uv * u_resolution / cellPx; + vec2 cellId = floor(gridUv); + vec2 cellUv = fract(gridUv) - 0.5; + vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution; + vec3 cellImg = texture2D(u_texture, sampleCenter).rgb; + float luma = dot(cellImg, vec3(0.299, 0.587, 0.114)); + // Darker cells → bigger magenta dots (classic risograph halftone curve). + float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56; + float dotMask = smoothstep(radius + 0.06, radius, length(cellUv)); + vec3 paper = vec3(0.975, 0.965, 0.955); + vec3 dotLayer = mix(paper, u_accent, dotMask); + + // Blend the halftone layer in where the roller is passing; leave the + // element pristine elsewhere. + vec3 base = texture2D(u_texture, uv).rgb; + gl_FragColor = vec4(mix(base, dotLayer, band), 1.0); +}`; + + // Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350)) + const SHADER_ACCENT = [0.82, 0.16, 0.47]; + let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + + function compileShader(gl, type, source) { + const sh = gl.createShader(type); + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(sh); + gl.deleteShader(sh); + throw new Error('shader compile failed: ' + info); + } + return sh; + } + + function positionShaderOverlay() { + if (!shaderState || !selectedElement) return; + const r = selectedElement.getBoundingClientRect(); + Object.assign(shaderState.canvas.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + } + + function hideShaderOverlay() { + if (!shaderState) return; + if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); + if (shaderState.canvas) shaderState.canvas.remove(); + const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + shaderState = null; + } + + async function showShaderOverlay(el, blob, rect) { + hideShaderOverlay(); + if (!blob || !el) return; + const canvas = document.createElement('canvas'); + canvas.id = PREFIX + '-shader'; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + Object.assign(canvas.style, { + position: 'fixed', + top: rect.top + 'px', left: rect.left + 'px', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', + zIndex: Z.bar - 1, + }); + document.body.appendChild(canvas); + + const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) + || canvas.getContext('experimental-webgl'); + if (!gl) { + // WebGL unavailable — fall back to a plain overlay so the user + // still sees something meaningful during generation. + canvas.remove(); + const img = document.createElement('img'); + img.src = URL.createObjectURL(blob); + img.id = PREFIX + '-shader'; + Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' }); + document.body.appendChild(img); + shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 }; + return; + } + + let program, texture; + try { + const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS); + program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error('program link failed: ' + gl.getProgramInfoLog(program)); + } + // Full-screen quad + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + -1, -1, 0, 1, + 1, -1, 1, 1, + -1, 1, 0, 0, + -1, 1, 0, 0, + 1, -1, 1, 1, + 1, 1, 1, 0, + ]), gl.STATIC_DRAW); + const posLoc = gl.getAttribLocation(program, 'a_position'); + const uvLoc = gl.getAttribLocation(program, 'a_uv'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(uvLoc); + gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8); + } catch (err) { + console.warn('[impeccable] shader setup failed:', err); + canvas.remove(); + return; + } + + // Upload the screenshot as a texture + let bitmap; + try { + bitmap = await createImageBitmap(blob); + } catch { + // Safari fallback: go via a regular Image + const imgUrl = URL.createObjectURL(blob); + const img = new Image(); + img.src = imgUrl; + await new Promise((r, rej) => { img.onload = r; img.onerror = rej; }); + bitmap = img; + URL.revokeObjectURL(imgUrl); + } + texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + if (bitmap.close) bitmap.close(); + + const uTime = gl.getUniformLocation(program, 'u_time'); + const uRes = gl.getUniformLocation(program, 'u_resolution'); + const uAccent = gl.getUniformLocation(program, 'u_accent'); + const uTex = gl.getUniformLocation(program, 'u_texture'); + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; + function frame() { + if (!shaderState) return; + const elapsed = (performance.now() - shaderState.startTime) / 1000; + const t = shaderState.reduced ? 0.0 : elapsed; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.useProgram(program); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.uniform1i(uTex, 0); + gl.uniform1f(uTime, t); + gl.uniform2f(uRes, canvas.width, canvas.height); + gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]); + gl.drawArrays(gl.TRIANGLES, 0, 6); + shaderState.rafId = requestAnimationFrame(frame); + } + frame(); } function handleAccept() { @@ -2713,6 +3600,7 @@ function init() { initHighlight(); + initAnnotOverlay(); initBar(); initActionPicker(); initGlobalBar(); diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs index 109aa768e..97163b255 100644 --- a/.github/skills/impeccable/scripts/live-server.mjs +++ b/.github/skills/impeccable/scripts/live-server.mjs @@ -18,7 +18,6 @@ import { randomUUID } from 'node:crypto'; import { spawn, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; @@ -56,8 +55,13 @@ const state = { pendingEvents: [], // browser events waiting for agent poll pendingPolls: [], // agent poll callbacks waiting for browser events exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots }; +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + function enqueueEvent(event) { if (state.pendingPolls.length > 0) { state.pendingPolls.shift()(event); @@ -134,6 +138,10 @@ function validateEvent(msg) { if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; return null; case 'accept': if (!msg.id) return 'accept: missing id'; @@ -175,6 +183,83 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { return; } + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + // --- Health --- if (p === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -250,10 +335,11 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } const absPath = path.resolve(process.cwd(), filePath); if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } - try { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(fs.readFileSync(absPath, 'utf-8')); - } catch { res.writeHead(404); res.end('File not found'); } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); return; } @@ -411,6 +497,9 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } for (const res of state.sseClients) { try { res.end(); } catch {} } state.sseClients.clear(); for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); @@ -442,12 +531,14 @@ Options: --help Show this help Endpoints: - /live.js Browser script (element picker + variant cycling) - /detect.js Detection overlay (backwards compatible) - /events SSE stream (server→browser) + POST (browser→server) - /poll Long-poll for agent CLI - /source Raw source file reader (no-HMR fallback) - /health Health check`); + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); process.exit(0); } @@ -531,6 +622,12 @@ try { state.token = randomUUID(); const portArg = args.find(a => a.startsWith('--port=')); state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); const { detectScript, liveScript } = loadBrowserScripts(); const liveScriptWithToken = diff --git a/.github/skills/impeccable/scripts/modern-screenshot.umd.js b/.github/skills/impeccable/scripts/modern-screenshot.umd.js new file mode 100644 index 000000000..a9c5208f6 --- /dev/null +++ b/.github/skills/impeccable/scripts/modern-screenshot.umd.js @@ -0,0 +1,14 @@ +(function(y,v){typeof exports=="object"&&typeof module!="undefined"?v(exports):typeof define=="function"&&define.amd?define(["exports"],v):(y=typeof globalThis!="undefined"?globalThis:y||self,v(y.modernScreenshot={}))})(this,function(y){"use strict";var rr=Object.defineProperty,nr=Object.defineProperties;var or=Object.getOwnPropertyDescriptors;var Z=Object.getOwnPropertySymbols;var xe=Object.prototype.hasOwnProperty,Me=Object.prototype.propertyIsEnumerable;var Oe=Math.pow,Le=(y,v,N)=>v in y?rr(y,v,{enumerable:!0,configurable:!0,writable:!0,value:N}):y[v]=N,D=(y,v)=>{for(var N in v||(v={}))xe.call(v,N)&&Le(y,N,v[N]);if(Z)for(var N of Z(v))Me.call(v,N)&&Le(y,N,v[N]);return y},M=(y,v)=>nr(y,or(v));var je=(y,v)=>{var N={};for(var R in y)xe.call(y,R)&&v.indexOf(R)<0&&(N[R]=y[R]);if(y!=null&&Z)for(var R of Z(y))v.indexOf(R)<0&&Me.call(y,R)&&(N[R]=y[R]);return N};var C=(y,v,N)=>new Promise((R,O)=>{var X=P=>{try{q(N.next(P))}catch(W){O(W)}},j=P=>{try{q(N.throw(P))}catch(W){O(W)}},q=P=>P.done?R(P.value):Promise.resolve(P.value).then(X,j);q((N=N.apply(y,v)).next())});var Be;function v(e,t){return e[13]=1,e[14]=t>>8,e[15]=t&255,e[16]=t>>8,e[17]=t&255,e}const N=112,R=72,O=89,X=115;let j;function q(){const e=new Int32Array(256);for(let t=0;t<256;t++){let r=t;for(let n=0;n<8;n++)r=r&1?3988292384^r>>>1:r>>>1;e[t]=r}return e}function P(e){let t=-1;j||(j=q());for(let r=0;r>>8;return t^-1}function W(e){const t=e.length-1;for(let r=t;r>=4;r--)if(e[r-4]===9&&e[r-3]===N&&e[r-2]===R&&e[r-1]===O&&e[r]===X)return r-3;return 0}function ae(e,t,r=!1){const n=new Uint8Array(13);t*=39.3701,n[0]=N,n[1]=R,n[2]=O,n[3]=X,n[4]=t>>>24,n[5]=t>>>16,n[6]=t>>>8,n[7]=t&255,n[8]=n[4],n[9]=n[5],n[10]=n[6],n[11]=n[7],n[12]=1;const i=P(n),a=new Uint8Array(4);if(a[0]=i>>>24,a[1]=i>>>16,a[2]=i>>>8,a[3]=i&255,r){const s=W(e);return e.set(n,s),e.set(a,s+13),e}else{const s=new Uint8Array(4);s[0]=0,s[1]=0,s[2]=0,s[3]=9;const o=new Uint8Array(54);return o.set(e,0),o.set(s,33),o.set(n,37),o.set(a,50),o}}const qe="AAlwSFlz",We="AAAJcEhZ",He="AAAACXBI";function Ve(e){let t=e.indexOf(qe);return t===-1&&(t=e.indexOf(We)),t===-1&&(t=e.indexOf(He)),t}const se="[modern-screenshot]",U=typeof window!="undefined",ze=U&&"Worker"in window,ie=U&&"atob"in window,Xe=U&&"btoa"in window,ee=U?(Be=window.navigator)==null?void 0:Be.userAgent:"",le=ee.includes("Chrome"),G=ee.includes("AppleWebKit")&&!le,te=ee.includes("Firefox"),Ge=e=>e&&"__CONTEXT__"in e,Ye=e=>e.constructor.name==="CSSFontFaceRule",Je=e=>e.constructor.name==="CSSImportRule",Ke=e=>e.constructor.name==="CSSLayerBlockRule",I=e=>e.nodeType===1,H=e=>typeof e.className=="object",ce=e=>e.tagName==="image",Qe=e=>e.tagName==="use",V=e=>I(e)&&typeof e.style!="undefined"&&!H(e),Ze=e=>e.nodeType===8,et=e=>e.nodeType===3,$=e=>e.tagName==="IMG",Y=e=>e.tagName==="VIDEO",tt=e=>e.tagName==="CANVAS",rt=e=>e.tagName==="TEXTAREA",nt=e=>e.tagName==="INPUT",ot=e=>e.tagName==="STYLE",at=e=>e.tagName==="SCRIPT",st=e=>e.tagName==="SELECT",it=e=>e.tagName==="SLOT",lt=e=>e.tagName==="IFRAME",ct=(...e)=>console.warn(se,...e);function ut(e){var r;const t=(r=e==null?void 0:e.createElement)==null?void 0:r.call(e,"canvas");return t&&(t.height=t.width=1),!!t&&"toDataURL"in t&&!!t.toDataURL("image/webp").includes("image/webp")}const re=e=>e.startsWith("data:");function ue(e,t){if(e.match(/^[a-z]+:\/\//i))return e;if(U&&e.match(/^\/\//))return window.location.protocol+e;if(e.match(/^[a-z]+:/i)||!U)return e;const r=J().implementation.createHTMLDocument(),n=r.createElement("base"),i=r.createElement("a");return r.head.appendChild(n),r.body.appendChild(i),t&&(n.href=t),i.href=e,i.href}function J(e){var t;return(t=e&&I(e)?e==null?void 0:e.ownerDocument:e)!=null?t:window.document}const K="http://www.w3.org/2000/svg";function fe(e,t,r){const n=J(r).createElementNS(K,"svg");return n.setAttributeNS(null,"width",e.toString()),n.setAttributeNS(null,"height",t.toString()),n.setAttributeNS(null,"viewBox",`0 0 ${e} ${t}`),n}function de(e,t){let r=new XMLSerializer().serializeToString(e);return t&&(r=r.replace(/[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu,"")),`data:image/svg+xml;charset=utf-8,${encodeURIComponent(r)}`}function ft(e,t="image/png",r=1){return C(this,null,function*(){try{return yield new Promise((n,i)=>{e.toBlob(a=>{a?n(a):i(new Error("Blob is null"))},t,r)})}catch(n){if(ie)return dt(e.toDataURL(t,r));throw n}})}function dt(e){var o,c;const[t,r]=e.split(","),n=(c=(o=t.match(/data:(.+);/))==null?void 0:o[1])!=null?c:void 0,i=window.atob(r),a=i.length,s=new Uint8Array(a);for(let u=0;u{const i=new FileReader;i.onload=()=>r(i.result),i.onerror=()=>n(i.error),i.onabort=()=>n(new Error(`Failed read blob to ${t}`)),t==="dataUrl"?i.readAsDataURL(e):t==="arrayBuffer"&&i.readAsArrayBuffer(e)})}const gt=e=>ge(e,"dataUrl"),mt=e=>ge(e,"arrayBuffer");function _(e,t){const r=J(t).createElement("img");return r.decoding="sync",r.loading="eager",r.src=e,r}function L(e,t){return new Promise(r=>{const{timeout:n,ownerDocument:i,onError:a,onWarn:s}=t!=null?t:{},o=typeof e=="string"?_(e,J(i)):e;let c=null,u=null;function l(){r(o),c&&clearTimeout(c),u==null||u()}if(n&&(c=setTimeout(l,n)),Y(o)){const d=o.currentSrc||o.src;if(!d)return o.poster?L(o.poster,t).then(r):l();if(o.readyState>=2)return l();const m=l,f=h=>{s==null||s("Failed video load",d,h),a==null||a(h),l()};u=()=>{o.removeEventListener("loadeddata",m),o.removeEventListener("error",f)},o.addEventListener("loadeddata",m,{once:!0}),o.addEventListener("error",f,{once:!0})}else{const d=ce(o)?o.href.baseVal:o.currentSrc||o.src;if(!d)return l();const m=()=>C(this,null,function*(){if($(o)&&"decode"in o)try{yield o.decode()}catch(h){s==null||s("Failed to decode image, trying to render anyway",o.dataset.originalSrc||d,h)}l()}),f=h=>{s==null||s("Failed image load",o.dataset.originalSrc||d,h),l()};if($(o)&&o.complete)return m();u=()=>{o.removeEventListener("load",m),o.removeEventListener("error",f)},o.addEventListener("load",m,{once:!0}),o.addEventListener("error",f,{once:!0})}})}function me(e,t){return C(this,null,function*(){V(e)&&($(e)||Y(e)?yield L(e,t):yield Promise.all(["img","video"].flatMap(r=>Array.from(e.querySelectorAll(r)).map(n=>L(n,t)))))})}const he=function(){let t=0;const r=()=>`0000${(Math.random()*Oe(36,4)<<0).toString(36)}`.slice(-4);return()=>(t+=1,`u${r()}${t}`)}();function we(e){return e==null?void 0:e.split(",").map(t=>t.trim().replace(/"|'/g,"").toLowerCase()).filter(Boolean)}let pe=0;function ht(e){const t=`${se}[#${pe}]`;return pe++,{time:r=>e&&console.time(`${t} ${r}`),timeEnd:r=>e&&console.timeEnd(`${t} ${r}`),warn:(...r)=>e&&ct(...r)}}function wt(e){return{cache:e?"no-cache":"force-cache"}}function k(e,t){return C(this,null,function*(){return Ge(e)?e:ye(e,M(D({},t),{autoDestruct:!0}))})}function ye(e,t){return C(this,null,function*(){var f,h,g,p,E;const{scale:r=1,workerUrl:n,workerNumber:i=1}=t||{},a=!!(t!=null&&t.debug),s=(f=t==null?void 0:t.features)!=null?f:!0,o=(h=e.ownerDocument)!=null?h:U?window.document:void 0,c=(p=(g=e.ownerDocument)==null?void 0:g.defaultView)!=null?p:U?window:void 0,u=new Map,l=M(D({width:0,height:0,quality:1,type:"image/png",scale:r,backgroundColor:null,style:null,filter:null,maximumCanvasSize:0,timeout:3e4,progress:null,debug:a,fetch:D({requestInit:wt((E=t==null?void 0:t.fetch)==null?void 0:E.bypassingCache),placeholderImage:"data:image/png;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",bypassingCache:!1},t==null?void 0:t.fetch),fetchFn:null,font:{},drawImageInterval:100,workerUrl:null,workerNumber:i,onCloneEachNode:null,onCloneNode:null,onEmbedNode:null,onCreateForeignObjectSvg:null,includeStyleProperties:null,autoDestruct:!1},t),{__CONTEXT__:!0,log:ht(a),node:e,ownerDocument:o,ownerWindow:c,dpi:r===1?null:96*r,svgStyleElement:be(o),svgDefsElement:o==null?void 0:o.createElementNS(K,"defs"),svgStyles:new Map,defaultComputedStyles:new Map,workers:[...Array.from({length:ze&&n&&i?i:0})].map(()=>{try{const b=new Worker(n);return b.onmessage=w=>C(this,null,function*(){var A,F,B,$e;const{url:S,result:T}=w.data;T?(F=(A=u.get(S))==null?void 0:A.resolve)==null||F.call(A,T):($e=(B=u.get(S))==null?void 0:B.reject)==null||$e.call(B,new Error(`Error receiving message from worker: ${S}`))}),b.onmessageerror=w=>{var T,A;const{url:S}=w.data;(A=(T=u.get(S))==null?void 0:T.reject)==null||A.call(T,new Error(`Error receiving message from worker: ${S}`))},b}catch(b){return l.log.warn("Failed to new Worker",b),null}}).filter(Boolean),fontFamilies:new Map,fontCssTexts:new Map,acceptOfImage:`${[ut(o)&&"image/webp","image/svg+xml","image/*","*/*"].filter(Boolean).join(",")};q=0.8`,requests:u,drawImageCount:0,tasks:[],features:s,isEnable:b=>{var w,S;return b==="restoreScrollPosition"?typeof s=="boolean"?!1:(w=s[b])!=null?w:!1:typeof s=="boolean"?s:(S=s[b])!=null?S:!0},shadowRoots:[]});l.log.time("wait until load"),yield me(e,{timeout:l.timeout,onWarn:l.log.warn}),l.log.timeEnd("wait until load");const{width:d,height:m}=pt(e,l);return l.width=d,l.height=m,l})}function be(e){if(!e)return;const t=e.createElement("style"),r=t.ownerDocument.createTextNode(` +.______background-clip--text { + background-clip: text; + -webkit-background-clip: text; +} +`);return t.appendChild(r),t}function pt(e,t){let{width:r,height:n}=t;if(I(e)&&(!r||!n)){const i=e.getBoundingClientRect();r=r||i.width||Number(e.getAttribute("width"))||0,n=n||i.height||Number(e.getAttribute("height"))||0}return{width:r,height:n}}function yt(e,t){return C(this,null,function*(){const{log:r,timeout:n,drawImageCount:i,drawImageInterval:a}=t;r.time("image to canvas");const s=yield L(e,{timeout:n,onWarn:t.log.warn}),{canvas:o,context2d:c}=bt(e.ownerDocument,t),u=()=>{try{c==null||c.drawImage(s,0,0,o.width,o.height)}catch(l){t.log.warn("Failed to drawImage",l)}};if(u(),t.isEnable("fixSvgXmlDecode"))for(let l=0;l{setTimeout(()=>{c==null||c.clearRect(0,0,o.width,o.height),u(),d()},l+a)});return t.drawImageCount=0,r.timeEnd("image to canvas"),o})}function bt(e,t){const{width:r,height:n,scale:i,backgroundColor:a,maximumCanvasSize:s}=t,o=e.createElement("canvas");o.width=Math.floor(r*i),o.height=Math.floor(n*i),o.style.width=`${r}px`,o.style.height=`${n}px`,s&&(o.width>s||o.height>s)&&(o.width>s&&o.height>s?o.width>o.height?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s):o.width>s?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s));const c=o.getContext("2d");return c&&a&&(c.fillStyle=a,c.fillRect(0,0,o.width,o.height)),{canvas:o,context2d:c}}function Se(e,t){if(e.ownerDocument)try{const a=e.toDataURL();if(a!=="data:,")return _(a,e.ownerDocument)}catch(a){t.log.warn("Failed to clone canvas",a)}const r=e.cloneNode(!1),n=e.getContext("2d"),i=r.getContext("2d");try{return n&&i&&i.putImageData(n.getImageData(0,0,e.width,e.height),0,0),r}catch(a){t.log.warn("Failed to clone canvas",a)}return r}function St(e,t){var r;try{if((r=e==null?void 0:e.contentDocument)!=null&&r.documentElement)return ne(e.contentDocument.documentElement,t)}catch(n){t.log.warn("Failed to clone iframe",n)}return e.cloneNode(!1)}function Et(e){const t=e.cloneNode(!1);return e.currentSrc&&e.currentSrc!==e.src&&(t.src=e.currentSrc,t.srcset=""),t.loading==="lazy"&&(t.loading="eager"),t}function Ct(e,t){return C(this,null,function*(){if(e.ownerDocument&&!e.currentSrc&&e.poster)return _(e.poster,e.ownerDocument);const r=e.cloneNode(!1);r.crossOrigin="anonymous",e.currentSrc&&e.currentSrc!==e.src&&(r.src=e.currentSrc);const n=r.ownerDocument;if(n){let i=!0;if(yield L(r,{onError:()=>i=!1,onWarn:t.log.warn}),!i)return e.poster?_(e.poster,e.ownerDocument):r;r.currentTime=e.currentTime,yield new Promise(s=>{r.addEventListener("seeked",s,{once:!0})});const a=n.createElement("canvas");a.width=e.offsetWidth,a.height=e.offsetHeight;try{const s=a.getContext("2d");s&&s.drawImage(r,0,0,a.width,a.height)}catch(s){return t.log.warn("Failed to clone video",s),e.poster?_(e.poster,e.ownerDocument):r}return Se(a,t)}return r})}function Tt(e,t){return tt(e)?Se(e,t):lt(e)?St(e,t):$(e)?Et(e):Y(e)?Ct(e,t):e.cloneNode(!1)}function vt(e){let t=e.sandbox;if(!t){const{ownerDocument:r}=e;try{r&&(t=r.createElement("iframe"),t.id=`__SANDBOX__${he()}`,t.width="0",t.height="0",t.style.visibility="hidden",t.style.position="fixed",r.body.appendChild(t),t.srcdoc='',e.sandbox=t)}catch(n){e.log.warn("Failed to getSandBox",n)}}return t}const At=["width","height","-webkit-text-fill-color"],Nt=["stroke","fill"];function Ee(e,t,r){const{defaultComputedStyles:n}=r,i=e.nodeName.toLowerCase(),a=H(e)&&i!=="svg",s=a?Nt.map(g=>[g,e.getAttribute(g)]).filter(([,g])=>g!==null):[],o=[a&&"svg",i,s.map((g,p)=>`${g}=${p}`).join(","),t].filter(Boolean).join(":");if(n.has(o))return n.get(o);const c=vt(r),u=c==null?void 0:c.contentWindow;if(!u)return new Map;const l=u==null?void 0:u.document;let d,m;a?(d=l.createElementNS(K,"svg"),m=d.ownerDocument.createElementNS(d.namespaceURI,i),s.forEach(([g,p])=>{m.setAttributeNS(null,g,p)}),d.appendChild(m)):d=m=l.createElement(i),m.textContent=" ",l.body.appendChild(d);const f=u.getComputedStyle(m,t),h=new Map;for(let g=f.length,p=0;pn.set(d,l));function s(c){const u=e.getPropertyValue(c),l=e.getPropertyPriority(c),d=c.lastIndexOf("-"),m=d>-1?c.substring(0,d):void 0;if(m){let f=a.get(m);f||(f=new Map,a.set(m,f)),f.set(c,[u,l])}t.get(c)===u&&!l||(m?i.push(m):n.set(c,[u,l]))}return n}function Rt(e,t,r,n){var d,m,f,h;const{ownerWindow:i,includeStyleProperties:a,currentParentNodeStyle:s}=n,o=t.style,c=i.getComputedStyle(e),u=Ee(e,null,n);s==null||s.forEach((g,p)=>{u.delete(p)});const l=Ce(c,u,a);l.delete("transition-property"),l.delete("all"),l.delete("d"),l.delete("content"),r&&(l.delete("position"),l.delete("margin-top"),l.delete("margin-right"),l.delete("margin-bottom"),l.delete("margin-left"),l.delete("margin-block-start"),l.delete("margin-block-end"),l.delete("margin-inline-start"),l.delete("margin-inline-end"),l.set("box-sizing",["border-box",""])),((d=l.get("background-clip"))==null?void 0:d[0])==="text"&&t.classList.add("______background-clip--text"),le&&(l.has("font-kerning")||l.set("font-kerning",["normal",""]),(((m=l.get("overflow-x"))==null?void 0:m[0])==="hidden"||((f=l.get("overflow-y"))==null?void 0:f[0])==="hidden")&&((h=l.get("text-overflow"))==null?void 0:h[0])==="ellipsis"&&e.scrollWidth===e.clientWidth&&l.set("text-overflow",["clip",""]));for(let g=o.length,p=0;p{o.setProperty(E,g,p)}),l}function It(e,t){(rt(e)||nt(e)||st(e))&&t.setAttribute("value",e.value)}const kt=["::before","::after"],Dt=["::-webkit-scrollbar","::-webkit-scrollbar-button","::-webkit-scrollbar-thumb","::-webkit-scrollbar-track","::-webkit-scrollbar-track-piece","::-webkit-scrollbar-corner","::-webkit-resizer"];function Pt(e,t,r,n,i){const{ownerWindow:a,svgStyleElement:s,svgStyles:o,currentNodeStyle:c}=n;if(!s||!a)return;function u(l){var w;const d=a.getComputedStyle(e,l);let m=d.getPropertyValue("content");if(!m||m==="none")return;i==null||i(m),m=m.replace(/(')|(")|(counter\(.+\))/g,"");const f=[he()],h=Ee(e,l,n);c==null||c.forEach((S,T)=>{h.delete(T)});const g=Ce(d,h,n.includeStyleProperties);g.delete("content"),g.delete("-webkit-locale"),((w=g.get("background-clip"))==null?void 0:w[0])==="text"&&t.classList.add("______background-clip--text");const p=[`content: '${m}';`];if(g.forEach(([S,T],A)=>{p.push(`${A}: ${S}${T?" !important":""};`)}),p.length===1)return;try{t.className=[t.className,...f].join(" ")}catch(S){n.log.warn("Failed to copyPseudoClass",S);return}const E=p.join(` + `);let b=o.get(E);b||(b=[],o.set(E,b)),b.push(`.${f[0]}${l}`)}kt.forEach(u),r&&Dt.forEach(u)}const Te=new Set(["symbol"]);function ve(e,t,r,n,i){return C(this,null,function*(){if(I(r)&&(ot(r)||at(r))||n.filter&&!n.filter(r))return;Te.has(t.nodeName)||Te.has(r.nodeName)?n.currentParentNodeStyle=void 0:n.currentParentNodeStyle=n.currentNodeStyle;const a=yield ne(r,n,!1,i);n.isEnable("restoreScrollPosition")&&Ut(e,a),t.appendChild(a)})}function Ae(e,t,r,n){return C(this,null,function*(){var a;let i=e.firstChild;I(e)&&e.shadowRoot&&(i=(a=e.shadowRoot)==null?void 0:a.firstChild,r.shadowRoots.push(e.shadowRoot));for(let s=i;s;s=s.nextSibling)if(!Ze(s))if(I(s)&&it(s)&&typeof s.assignedNodes=="function"){const o=s.assignedNodes();for(let c=0;ce.clientHeight||e.scrollWidth>e.clientWidth)}const p=(d=h.get("text-transform"))==null?void 0:d[0],E=we((m=h.get("font-family"))==null?void 0:m[0]),b=E?w=>{p==="uppercase"?w=w.toUpperCase():p==="lowercase"?w=w.toLowerCase():p==="capitalize"&&(w=w[0].toUpperCase()+w.substring(1)),E.forEach(S=>{let T=s.get(S);T||s.set(S,T=new Set),w.split("").forEach(A=>T.add(A))})}:void 0;return Pt(e,f,g,t,b),It(e,f),Y(e)||(yield Ae(e,f,t,b)),yield o==null?void 0:o(f),f}const c=e.cloneNode(!1);return yield Ae(e,c,t),yield o==null?void 0:o(c),c})}function Ne(e){if(e.ownerDocument=void 0,e.ownerWindow=void 0,e.svgStyleElement=void 0,e.svgDefsElement=void 0,e.svgStyles.clear(),e.defaultComputedStyles.clear(),e.sandbox){try{e.sandbox.remove()}catch(t){e.log.warn("Failed to destroyContext",t)}e.sandbox=void 0}e.workers=[],e.fontFamilies.clear(),e.fontCssTexts.clear(),e.requests.clear(),e.tasks=[],e.shadowRoots=[]}function Bt(e){const o=e,{url:t,timeout:r,responseType:n}=o,i=je(o,["url","timeout","responseType"]),a=new AbortController,s=r?setTimeout(()=>a.abort(),r):void 0;return fetch(t,D({signal:a.signal},i)).then(c=>{if(!c.ok)throw new Error("Failed fetch, not 2xx response",{cause:c});switch(n){case"arrayBuffer":return c.arrayBuffer();case"dataUrl":return c.blob().then(gt);case"text":default:return c.text()}}).finally(()=>clearTimeout(s))}function z(e,t){const{url:r,requestType:n="text",responseType:i="text",imageDom:a}=t;let s=r;const{timeout:o,acceptOfImage:c,requests:u,fetchFn:l,fetch:{requestInit:d,bypassingCache:m,placeholderImage:f},font:h,workers:g,fontFamilies:p}=e;n==="image"&&(G||te)&&e.drawImageCount++;let E=u.get(r);if(!E){m&&m instanceof RegExp&&m.test(s)&&(s+=(/\?/.test(s)?"&":"?")+new Date().getTime());const b=n.startsWith("font")&&h&&h.minify,w=new Set;b&&n.split(";")[1].split(",").forEach(F=>{p.has(F)&&p.get(F).forEach(B=>w.add(B))});const S=b&&w.size,T=D({url:s,timeout:o,responseType:S?"arrayBuffer":i,headers:n==="image"?{accept:c}:void 0},d);E={type:n,resolve:void 0,reject:void 0,response:null},E.response=C(this,null,function*(){if(l&&n==="image"){const A=yield l(r);if(A)return A}return!G&&r.startsWith("http")&&g.length?new Promise((A,F)=>{g[u.size&g.length-1].postMessage(D({rawUrl:r},T)),E.resolve=A,E.reject=F}):Bt(T)}).catch(A=>{if(u.delete(r),n==="image"&&f)return e.log.warn("Failed to fetch image base64, trying to use placeholder image",s),typeof f=="string"?f:f(a);throw A}),u.set(r,E)}return E.response}function Re(e,t,r,n){return C(this,null,function*(){if(!Ie(e))return e;for(const[i,a]of $t(e,t))try{const s=yield z(r,{url:a,requestType:n?"image":"text",responseType:"dataUrl"});e=e.replace(Lt(i),`$1${s}$3`)}catch(s){r.log.warn("Failed to fetch css data url",i,s)}return e})}function Ie(e){return/url\((['"]?)([^'"]+?)\1\)/.test(e)}const ke=/url\((['"]?)([^'"]+?)\1\)/g;function $t(e,t){const r=[];return e.replace(ke,(n,i,a)=>(r.push([a,ue(a,t)]),n)),r.filter(([n])=>!re(n))}function Lt(e){const t=e.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${t})(['"]?\\))`,"g")}const xt=["background-image","border-image-source","-webkit-border-image","-webkit-mask-image","list-style-image"];function Mt(e,t){return xt.map(r=>{const n=e.getPropertyValue(r);return!n||n==="none"?null:((G||te)&&t.drawImageCount++,Re(n,null,t,!0).then(i=>{!i||n===i||e.setProperty(r,i,e.getPropertyPriority(r))}))}).filter(Boolean)}function Ot(e,t){if($(e)){const r=e.currentSrc||e.src;if(!re(r))return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.srcset="",e.dataset.originalSrc=r,e.src=n||"")})];(G||te)&&t.drawImageCount++}else if(H(e)&&!re(e.href.baseVal)){const r=e.href.baseVal;return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.dataset.originalSrc=r,e.href.baseVal=n||"")})]}return[]}function jt(e,t){var o;const{ownerDocument:r,svgDefsElement:n}=t,i=(o=e.getAttribute("href"))!=null?o:e.getAttribute("xlink:href");if(!i)return[];const[a,s]=i.split("#");if(s){const c=`#${s}`,u=t.shadowRoots.reduce((l,d)=>l!=null?l:d.querySelector(`svg ${c}`),r==null?void 0:r.querySelector(`svg ${c}`));if(a&&e.setAttribute("href",c),n!=null&&n.querySelector(c))return[];if(u)return n==null||n.appendChild(u.cloneNode(!0)),[];if(a)return[z(t,{url:a,responseType:"text"}).then(l=>{n==null||n.insertAdjacentHTML("beforeend",l)})]}return[]}function De(e,t){const{tasks:r}=t;I(e)&&(($(e)||ce(e))&&r.push(...Ot(e,t)),Qe(e)&&r.push(...jt(e,t))),V(e)&&r.push(...Mt(e.style,t)),e.childNodes.forEach(n=>{De(n,t)})}function qt(e,t){return C(this,null,function*(){const{ownerDocument:r,svgStyleElement:n,fontFamilies:i,fontCssTexts:a,tasks:s,font:o}=t;if(!(!r||!n||!i.size))if(o&&o.cssText){const c=Ue(o.cssText,t);n.appendChild(r.createTextNode(`${c} +`))}else{const c=Array.from(r.styleSheets).filter(f=>{try{return"cssRules"in f&&!!f.cssRules.length}catch(h){return t.log.warn(`Error while reading CSS rules from ${f.href}`,h),!1}}),u=r.implementation.createHTMLDocument(""),l=u.createElement("style");u.head.appendChild(l);const d=l.sheet;yield Promise.all(c.flatMap(f=>Array.from(f.cssRules).map(h=>C(this,null,function*(){if(Je(h)){const g=h.href;let p="";try{p=yield z(t,{url:g,requestType:"text",responseType:"text"})}catch(b){t.log.warn(`Error fetch remote css import from ${g}`,b)}const E=p.replace(ke,(b,w,S)=>b.replace(S,ue(S,g)));for(const b of Ht(E))try{d.insertRule(b,d.cssRules.length)}catch(w){t.log.warn("Error inserting rule from remote css import",{rule:b,error:w})}}})))),d.cssRules.length&&c.push(d);const m=[];c.forEach(f=>{oe(f.cssRules,m)}),m.filter(f=>{var h;return Ye(f)&&Ie(f.style.getPropertyValue("src"))&&((h=we(f.style.getPropertyValue("font-family")))==null?void 0:h.some(g=>i.has(g)))}).forEach(f=>{const h=f,g=a.get(h.cssText);g?n.appendChild(r.createTextNode(`${g} +`)):s.push(Re(h.cssText,h.parentStyleSheet?h.parentStyleSheet.href:null,t).then(p=>{p=Ue(p,t),a.set(h.cssText,p),n.appendChild(r.createTextNode(`${p} +`))}))})}})}const Wt=/(\/\*[\s\S]*?\*\/)/g,Pe=/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi;function Ht(e){if(e==null)return[];const t=[];let r=e.replace(Wt,"");for(;;){const a=Pe.exec(r);if(!a)break;t.push(a[0])}r=r.replace(Pe,"");const n=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,i=new RegExp("((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})","gi");for(;;){let a=n.exec(r);if(a)i.lastIndex=n.lastIndex;else if(a=i.exec(r),a)n.lastIndex=i.lastIndex;else break;t.push(a[0])}return t}const Vt=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,zt=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Ue(e,t){const{font:r}=t,n=r?r==null?void 0:r.preferredFormat:void 0;return n?e.replace(zt,i=>{for(;;){const[a,,s]=Vt.exec(i)||[];if(!s)return"";if(s===n)return`src: ${a};`}}):e}function oe(e,t=[]){for(const r of Array.from(e))Ke(r)?t.push(...oe(r.cssRules)):"cssRules"in r?oe(r.cssRules,t):t.push(r);return t}const Xt=/\bx?link:?href\s*=\s*["'](?!data:)[^"']+["']/i;function Gt(e){return Xt.test(e.innerHTML)}function _e(e,t){return C(this,null,function*(){const r=yield k(e,t);if(I(r.node)&&H(r.node)&&!Gt(r.node))return r.node;const{ownerDocument:n,log:i,tasks:a,svgStyleElement:s,svgDefsElement:o,svgStyles:c,font:u,progress:l,autoDestruct:d,onCloneNode:m,onEmbedNode:f,onCreateForeignObjectSvg:h}=r;i.time("clone node");const g=yield ne(r.node,r,!0);if(s&&n){let S="";c.forEach((T,A)=>{S+=`${T.join(`, +`)} { + ${A} +} +`}),s.appendChild(n.createTextNode(S))}i.timeEnd("clone node"),yield m==null?void 0:m(g),u!==!1&&I(g)&&(i.time("embed web font"),yield qt(g,r),i.timeEnd("embed web font")),i.time("embed node"),De(g,r);const p=a.length;let E=0;const b=()=>C(this,null,function*(){for(;;){const S=a.pop();if(!S)break;try{yield S}catch(T){r.log.warn("Failed to run task",T)}l==null||l(++E,p)}});l==null||l(E,p),yield Promise.all([...Array.from({length:4})].map(b)),i.timeEnd("embed node"),yield f==null?void 0:f(g);const w=Yt(g,r);return o&&w.insertBefore(o,w.children[0]),s&&w.insertBefore(s,w.children[0]),d&&Ne(r),yield h==null?void 0:h(w),w})}function Yt(e,t){const{width:r,height:n}=t,i=fe(r,n,e.ownerDocument),a=i.ownerDocument.createElementNS(i.namespaceURI,"foreignObject");return a.setAttributeNS(null,"x","0%"),a.setAttributeNS(null,"y","0%"),a.setAttributeNS(null,"width","100%"),a.setAttributeNS(null,"height","100%"),a.append(e),i.appendChild(a),i}function Q(e,t){return C(this,null,function*(){var s;const r=yield k(e,t),n=yield _e(r),i=de(n,r.isEnable("removeControlCharacter"));r.autoDestruct||(r.svgStyleElement=be(r.ownerDocument),r.svgDefsElement=(s=r.ownerDocument)==null?void 0:s.createElementNS(K,"defs"),r.svgStyles.clear());const a=_(i,n.ownerDocument);return yield yt(a,r)})}function Jt(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,type:i,quality:a,dpi:s}=r,o=yield Q(r);n.time("canvas to blob");const c=yield ft(o,i,a);if(["image/png","image/jpeg"].includes(i)&&s){const u=yield mt(c.slice(0,33));let l=new Uint8Array(u);return i==="image/png"?l=ae(l,s):i==="image/jpeg"&&(l=v(l,s)),n.timeEnd("canvas to blob"),new Blob([l,c.slice(33)],{type:i})}return n.timeEnd("canvas to blob"),c})}function x(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,quality:i,type:a,dpi:s}=r,o=yield Q(r);n.time("canvas to data url");let c=o.toDataURL(a,i);if(["image/png","image/jpeg"].includes(a)&&s&&ie&&Xe){const[u,l]=c.split(",");let d=0,m=!1;if(a==="image/png"){const w=Ve(l);w>=0?(d=Math.ceil((w+28)/3)*4,m=!0):d=33/3*4}else a==="image/jpeg"&&(d=18/3*4);const f=l.substring(0,d),h=l.substring(d),g=window.atob(f),p=new Uint8Array(g.length);for(let w=0;w mirroring selectedElement's bounding rect. Click (no + // drag) drops a comment pin; drag paints a magenta SVG stroke. All coords + // are stored in element-local CSS px so they survive scroll / resize and + // correlate directly with the captured PNG. + // --------------------------------------------------------------------------- + + const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click + const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it + let annotOverlayEl = null; + let annotSvgEl = null; + let annotPinsEl = null; + let annotClearChipEl = null; + let annotState = { comments: [], strokes: [] }; + let annotActive = false; + // `annotPointer` is either: + // { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin + // { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin + let annotPointer = null; + let annotEditing = null; // { idx, input, wrapEl } + let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete + + function initAnnotOverlay() { + annotOverlayEl = document.createElement('div'); + annotOverlayEl.id = PREFIX + '-annot'; + Object.assign(annotOverlayEl.style, { + position: 'fixed', top: '0', left: '0', width: '0', height: '0', + pointerEvents: 'auto', zIndex: Z.highlight + 2, + display: 'none', overflow: 'visible', + cursor: 'crosshair', touchAction: 'none', + }); + + annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + annotSvgEl.id = PREFIX + '-annot-svg'; + Object.assign(annotSvgEl.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', + // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via + // pointer-events=stroke so gaps still fall through to the overlay. + pointerEvents: 'none', overflow: 'visible', + }); + annotOverlayEl.appendChild(annotSvgEl); + + annotPinsEl = document.createElement('div'); + annotPinsEl.id = PREFIX + '-annot-pins'; + Object.assign(annotPinsEl.style, { + position: 'absolute', inset: '0', + pointerEvents: 'none', + }); + annotOverlayEl.appendChild(annotPinsEl); + + annotClearChipEl = document.createElement('div'); + annotClearChipEl.id = PREFIX + '-annot-clear'; + annotClearChipEl.dataset.annotClear = 'true'; + annotClearChipEl.textContent = 'Clear'; + Object.assign(annotClearChipEl.style, { + position: 'absolute', top: '8px', right: '8px', + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '10px', fontWeight: '500', + letterSpacing: '0.08em', textTransform: 'uppercase', + padding: '5px 12px', borderRadius: '999px', + cursor: 'pointer', pointerEvents: 'auto', + display: 'none', userSelect: 'none', + boxShadow: '0 1px 3px rgba(0,0,0,0.2)', + }); + annotOverlayEl.appendChild(annotClearChipEl); + + annotOverlayEl.addEventListener('pointerdown', onAnnotDown); + annotOverlayEl.addEventListener('pointermove', onAnnotMove); + annotOverlayEl.addEventListener('pointerup', onAnnotUp); + annotOverlayEl.addEventListener('pointercancel', onAnnotUp); + document.body.appendChild(annotOverlayEl); + } + + function updateClearChip() { + if (!annotClearChipEl) return; + const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0; + annotClearChipEl.style.display = hasAny ? 'block' : 'none'; + } + + function showAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + annotActive = true; + positionAnnotOverlay(el); + annotOverlayEl.style.display = 'block'; + } + + function hideAnnotOverlay() { + annotActive = false; + if (annotOverlayEl) annotOverlayEl.style.display = 'none'; + // Drop any in-progress edit without touching annotState — clearAnnotations + // (if the caller is exiting configure mode) handles state reset. + annotEditing = null; + } + + function positionAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + const r = el.getBoundingClientRect(); + Object.assign(annotOverlayEl.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height); + } + + function clearAnnotations() { + annotState.comments = []; + annotState.strokes = []; + if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + if (annotPinsEl) annotPinsEl.innerHTML = ''; + annotPointer = null; + annotEditing = null; + annotLastPinClick = { idx: -1, time: 0 }; + updateClearChip(); + } + + // Rebuild the SVG layer. Each stroke gets a wider invisible hit path + // beneath the visible magenta path so clicks register on thin lines. + function redrawStrokes() { + while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + annotState.strokes.forEach((s, idx) => { + const d = pointsToPath(s.points); + const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + hit.setAttribute('d', d); + hit.setAttribute('stroke', 'transparent'); + hit.setAttribute('stroke-width', '16'); + hit.setAttribute('stroke-linecap', 'round'); + hit.setAttribute('stroke-linejoin', 'round'); + hit.setAttribute('fill', 'none'); + hit.setAttribute('pointer-events', 'stroke'); + hit.style.cursor = 'pointer'; + hit.dataset.annotStroke = String(idx); + annotSvgEl.appendChild(hit); + const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + visible.setAttribute('d', d); + visible.setAttribute('stroke', C.brand); + visible.setAttribute('stroke-width', '3'); + visible.setAttribute('stroke-linecap', 'round'); + visible.setAttribute('stroke-linejoin', 'round'); + visible.setAttribute('fill', 'none'); + visible.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(visible); + }); + updateClearChip(); + } + + function localCoords(e) { + const rect = annotOverlayEl.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + function onAnnotDown(e) { + if (!annotActive) return; + + // 1) Clear chip → wipe all annotations + if (e.target.closest?.('[data-annot-clear]')) { + if (annotEditing) annotEditing = null; + clearAnnotations(); + renderAllPins(); + redrawStrokes(); + e.stopPropagation(); e.preventDefault(); + return; + } + + // 2) Stroke hit path → delete that stroke + const strokeHit = e.target.closest?.('[data-annot-stroke]'); + if (strokeHit) { + const idx = parseInt(strokeHit.dataset.annotStroke, 10); + if (Number.isInteger(idx)) { + annotState.strokes.splice(idx, 1); + redrawStrokes(); + } + e.stopPropagation(); e.preventDefault(); + return; + } + + // 3) Pin → drag, edit, or delete-on-double-click + const pinWrap = e.target.closest?.('[data-annot-pin]'); + if (pinWrap) { + const idx = parseInt(pinWrap.dataset.annotPin, 10); + if (!Number.isInteger(idx)) return; + // Double-click (two pointerdowns on the same pin within window) → delete. + const now = Date.now(); + if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) { + if (annotEditing && annotEditing.idx === idx) annotEditing = null; + annotState.comments.splice(idx, 1); + annotLastPinClick = { idx: -1, time: 0 }; + renderAllPins(); + e.stopPropagation(); e.preventDefault(); + return; + } + annotLastPinClick = { idx, time: now }; + // If editing a different pin, commit that edit before starting here. + if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin(); + // If already editing THIS pin and the user clicked the dot, let the + // input keep focus (don't start a drag — the click wasn't meant as one). + if (annotEditing && annotEditing.idx === idx) return; + const p = localCoords(e); + const pin = annotState.comments[idx]; + annotPointer = { + kind: 'pin', idx, + startPointer: p, + startPin: { x: pin.x, y: pin.y }, + moved: false, + }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + return; + } + + // 4) Empty area → commit any open edit, then start new annotation + if (annotEditing) { + finalizeEditingPin(); + e.stopPropagation(); e.preventDefault(); + return; + } + const p = localCoords(e); + annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + } + + function onAnnotMove(e) { + if (!annotActive || !annotPointer) return; + const p = localCoords(e); + + if (annotPointer.kind === 'pin') { + const dx = p.x - annotPointer.startPointer.x; + const dy = p.y - annotPointer.startPointer.y; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + } + const pin = annotState.comments[annotPointer.idx]; + if (!pin) { annotPointer = null; return; } + pin.x = annotPointer.startPin.x + dx; + pin.y = annotPointer.startPin.y + dy; + renderAllPins(); + e.stopPropagation(); + return; + } + + // kind === 'new' + const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + strokeEl.setAttribute('stroke', C.brand); + strokeEl.setAttribute('stroke-width', '3'); + strokeEl.setAttribute('stroke-linecap', 'round'); + strokeEl.setAttribute('stroke-linejoin', 'round'); + strokeEl.setAttribute('fill', 'none'); + strokeEl.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(strokeEl); + annotPointer.strokeEl = strokeEl; + annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]]; + } + annotPointer.strokePoints.push([p.x, p.y]); + annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints)); + e.stopPropagation(); + } + + function onAnnotUp(e) { + if (!annotActive || !annotPointer) return; + + if (annotPointer.kind === 'pin') { + const wasDrag = annotPointer.moved; + const idx = annotPointer.idx; + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + if (wasDrag) { + // A drag is an intentional reposition; a follow-up click shouldn't be + // interpreted as a double-click-to-delete. + annotLastPinClick = { idx: -1, time: 0 }; + } else { + beginEditPin(idx); + } + e.stopPropagation(); + return; + } + + // kind === 'new' + const wasDrag = annotPointer.moved; + if (wasDrag) { + annotState.strokes.push({ points: annotPointer.strokePoints }); + // Swap the temporary preview SVG path for the full render with hit paths. + redrawStrokes(); + } else { + const idx = annotState.comments.length; + annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' }); + renderAllPins(); + beginEditPin(idx); + } + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + e.stopPropagation(); + } + + function pointsToPath(points) { + if (!points || points.length === 0) return ''; + let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1); + for (let i = 1; i < points.length; i++) { + d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1); + } + return d; + } + + function renderAllPins() { + annotPinsEl.innerHTML = ''; + annotState.comments.forEach((c, idx) => { + annotPinsEl.appendChild(buildPinElement(c, idx)); + }); + updateClearChip(); + } + + function buildPinElement(comment, idx) { + const interactive = idx >= 0; + const wrap = document.createElement('div'); + if (interactive) wrap.dataset.annotPin = String(idx); + Object.assign(wrap.style, { + position: 'absolute', + left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px', + pointerEvents: interactive ? 'auto' : 'none', + display: 'flex', alignItems: 'flex-start', gap: '6px', + cursor: interactive ? 'grab' : 'default', + touchAction: 'none', + }); + const dot = document.createElement('div'); + Object.assign(dot.style, { + width: '14px', height: '14px', borderRadius: '50%', + background: C.brand, border: '2px solid ' + C.white, + boxShadow: '0 1px 3px rgba(0,0,0,0.25)', + flexShrink: '0', + }); + wrap.appendChild(dot); + + if (comment.text) { + const bubble = document.createElement('div'); + bubble.textContent = comment.text; + Object.assign(bubble.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + marginTop: '-2px', maxWidth: '220px', + pointerEvents: 'none', whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }); + wrap.appendChild(bubble); + } + return wrap; + } + + function beginEditPin(idx) { + const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]'); + if (!wrapEl) return; + // Strip any existing bubble (but keep the dot) + wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove()); + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Note…'; + Object.assign(input.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + border: '1px solid ' + C.brand, + outline: 'none', marginTop: '-2px', + width: '220px', pointerEvents: 'auto', + }); + const originalText = annotState.comments[idx].text || ''; + input.value = originalText; + wrapEl.appendChild(input); + annotEditing = { idx, input, wrapEl, originalText }; + input.addEventListener('keydown', onAnnotInputKey, true); + input.addEventListener('blur', () => { + // Fires on both focus-loss and programmatic blur; commit unless we + // already handled it. + if (annotEditing && annotEditing.input === input) finalizeEditingPin(); + }); + // Stop clicks/pointerdowns inside the input from bubbling to the overlay + ['pointerdown', 'click'].forEach(ev => { + input.addEventListener(ev, e => e.stopPropagation()); + }); + setTimeout(() => input.focus(), 0); + } + + function onAnnotInputKey(e) { + if (e.key === 'Enter') { + e.preventDefault(); e.stopPropagation(); + finalizeEditingPin(); + } else if (e.key === 'Escape') { + e.preventDefault(); e.stopPropagation(); + cancelEditingPin(); + } else { + // Keep arrows / backspace from hitting global handlers + e.stopPropagation(); + } + } + + function finalizeEditingPin() { + if (!annotEditing) return; + const { idx, input } = annotEditing; + const text = input.value.trim(); + annotEditing = null; + if (text) annotState.comments[idx].text = text; + else annotState.comments.splice(idx, 1); + renderAllPins(); + } + + function cancelEditingPin() { + if (!annotEditing) return; + const { idx, originalText } = annotEditing; + annotEditing = null; + // If the pin had text before this edit, revert to it. If it was a + // just-created empty pin, Escape removes it. + if (originalText) { + annotState.comments[idx].text = originalText; + } else { + annotState.comments.splice(idx, 1); + } + renderAllPins(); + } + + // Build a detached annotation subtree suitable for injection into the clone + // modern-screenshot creates. Coordinates are element-local so this slots + // straight into an element that's been made position:relative. Takes an + // explicit snapshot so it works after annotState has been cleared. + function buildAnnotationsForCapture(rect, snapshot) { + const comments = snapshot ? snapshot.comments : annotState.comments; + const strokes = snapshot ? snapshot.strokes : annotState.strokes; + if (comments.length === 0 && strokes.length === 0) return null; + const wrap = document.createElement('div'); + Object.assign(wrap.style, { + position: 'absolute', top: '0', left: '0', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', overflow: 'visible', + }); + if (strokes.length > 0) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height); + Object.assign(svg.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', overflow: 'visible', + }); + for (const s of strokes) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('stroke', C.brand); + path.setAttribute('stroke-width', '3'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('fill', 'none'); + path.setAttribute('d', pointsToPath(s.points)); + svg.appendChild(path); + } + wrap.appendChild(svg); + } + for (const c of comments) { + // idx=-1 means non-interactive; pointerEvents stay off in the clone + wrap.appendChild(buildPinElement(c, -1)); + } + return wrap; } // --------------------------------------------------------------------------- @@ -416,8 +901,10 @@ fontSize: '11px', color: C.ash, whiteSpace: 'nowrap', marginLeft: 'auto', }); + // Variants currently arrive atomically in a single file edit, so a + // per-variant counter would lie. Say what's true. status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + (arrivedVariants + 1) + ' of ' + expectedVariants + '...' + ? 'Generating ' + expectedVariants + ' variants...' : 'Done'; row.appendChild(status); @@ -838,6 +1325,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + hideShaderOverlay(); updateBarContent('cycling'); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -860,6 +1348,10 @@ positionBar(); showHighlight(selectedElement); } + if (annotActive) positionAnnotOverlay(selectedElement); + // Shader overlay (via debug P toggle or generation) is repositioned + // by its own branch below; debug no longer has a separate overlay. + if (shaderState) positionShaderOverlay(); scrollRaf = requestAnimationFrame(tick); } scrollRaf = requestAnimationFrame(tick); @@ -937,6 +1429,8 @@ } hideBar(); hideHighlight(); + hideShaderOverlay(); + hideAnnotOverlay(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } clearSession(); @@ -976,6 +1470,8 @@ if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { hideBar(); stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -989,15 +1485,19 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); } function handleKeyDown(e) { + // When the annotation input is focused, let it handle its own keys. + if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); state = 'PICKING'; return; } + if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { hideHighlight(); state = 'IDLE'; return; } @@ -1024,6 +1524,8 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); return; @@ -1035,6 +1537,8 @@ } else { // CONFIGURING: re-select the new element and refresh the bar selectedElement = next; + clearAnnotations(); + showAnnotOverlay(next); showBar('configure'); startScrollTracking(); } @@ -1056,25 +1560,408 @@ const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; + // Commit any pending pin edit BEFORE we snapshot annotations. + if (annotEditing) finalizeEditingPin(); + currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; - sendEvent({ + // Flip to GENERATING immediately so the bar morphs without waiting on + // capture + upload. The event is emitted from captureAndEmit() once the + // screenshot is uploaded (or capture fails — we still emit, just without + // screenshotPath). + const elForCapture = selectedElement; + const captureRect = elForCapture.getBoundingClientRect(); + const snapshot = { + comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), + strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })), + }; + const basePayload = { type: 'generate', id: currentSessionId, action: selectedAction, freeformPrompt: prompt || undefined, count: selectedCount, pageUrl: location.pathname, - element: extractContext(selectedElement), - }); + element: extractContext(elForCapture), + }; + if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments; + if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes; + + // Hide the interactive overlay so it doesn't linger during generation. + hideAnnotOverlay(); + clearAnnotations(); state = 'GENERATING'; showBar('generating'); saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + + captureAndEmit(elForCapture, basePayload, snapshot, captureRect); + } + + // --------------------------------------------------------------------------- + // Screenshot capture + upload + // --------------------------------------------------------------------------- + + let msLoadPromise = null; + function loadModernScreenshot() { + if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot); + if (msLoadPromise) return msLoadPromise; + msLoadPromise = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; + s.onload = () => resolve(window.modernScreenshot); + s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; + document.head.appendChild(s); + }); + return msLoadPromise; + } + + // Collect @font-face rules from every stylesheet on the page. Cross-origin + // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules + // access, so modern-screenshot can't embed them on its own — the resulting + // SVG falls back to system fonts and text re-wraps + renders with different + // weight. We fetch the raw CSS text (CORS-permitted for these providers), + // extract @font-face blocks, inline the referenced font files as base64 + // data URIs (SVGs rasterized via canvas can't fetch external resources, + // so URLs inside the SVG silently fail without this), and pass the result + // to modern-screenshot as font.cssText. + const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i; + const FONT_MIME = { + woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject', + }; + function bufferToBase64(buf) { + const bytes = new Uint8Array(buf); + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); + } + async function inlineFontUrls(cssText) { + const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g; + const urls = new Set(); + let m; + while ((m = urlRe.exec(cssText))) { + if (FONT_EXT_RE.test(m[2])) urls.add(m[2]); + } + const map = new Map(); + await Promise.all([...urls].map(async (url) => { + try { + const res = await fetch(url); + if (!res.ok) return; + const buf = await res.arrayBuffer(); + const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2'; + const mime = FONT_MIME[ext] || 'application/octet-stream'; + map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf)); + } catch { /* skip; fall through to URL */ } + })); + return cssText.replace(urlRe, (orig, q, url) => { + const data = map.get(url); + return data ? 'url(' + q + data + q + ')' : orig; + }); + } + async function collectFontCssText() { + const chunks = []; + const fontFaceRe = /@font-face\s*\{[^}]*\}/g; + for (const sheet of document.styleSheets) { + try { + const rules = sheet.cssRules; + for (const rule of rules) { + if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) { + chunks.push(rule.cssText); + } + } + } catch { + if (!sheet.href) continue; + try { + const res = await fetch(sheet.href); + if (!res.ok) continue; + const text = await res.text(); + let m2; + while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]); + } catch { /* ignore; capture is best-effort */ } + } + } + if (chunks.length === 0) return ''; + return inlineFontUrls(chunks.join('\n')); + } + + // Capture the element (with current annotations baked in) and return a PNG + // Blob. Shared between the Go flow (uploads it to the server) and the + // debug toggle (displays it as an overlay for side-by-side comparison). + async function captureElementToBlob(el, snapshot, rect) { + try { if (document.fonts?.ready) await document.fonts.ready; } catch {} + const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); + let annotNode = null; + let savedPosition = null; + if (hasAnnotations) { + const pos = getComputedStyle(el).position; + if (pos === 'static') { + savedPosition = el.style.position; + el.style.position = 'relative'; + } + annotNode = buildAnnotationsForCapture(rect, snapshot); + el.appendChild(annotNode); + } + try { + const ms = await loadModernScreenshot(); + const fontCssText = await collectFontCssText(); + return await ms.domToBlob(el, { + scale: Math.min(window.devicePixelRatio || 1, 2), + backgroundColor: getComputedStyle(document.body).backgroundColor || '#ffffff', + font: fontCssText ? { cssText: fontCssText } : undefined, + }); + } finally { + if (annotNode) annotNode.remove(); + if (savedPosition !== null) el.style.position = savedPosition; + } + } + + async function captureAndEmit(el, basePayload, snapshot, rect) { + let screenshotPath; + let blob; + try { + blob = await captureElementToBlob(el, snapshot, rect); + } catch (err) { + console.warn('[impeccable] capture failed, proceeding without screenshot:', err); + } + // Light up the shader overlay the moment capture is ready — no reason to + // wait for the upload to complete before the user sees something alive. + if (blob && state === 'GENERATING') { + showShaderOverlay(el, blob, rect); + } + if (blob) { + try { + const uploadRes = await fetch( + 'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) + + '&eventId=' + encodeURIComponent(basePayload.id), + { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob }, + ); + if (uploadRes.ok) { + const { path: p } = await uploadRes.json(); + screenshotPath = p; + } else { + console.warn('[impeccable] annotation upload failed:', uploadRes.status); + } + } catch (err) { + console.warn('[impeccable] annotation upload failed:', err); + } + } + sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); + } + + // --------------------------------------------------------------------------- + // Shader overlay — renders the captured screenshot as a WebGL texture and + // runs an editorial "ink-wash" fragment shader over it during generation. + // A single rolling band sweeps top-to-bottom, desaturating + tinting magenta + // and leaving a soft trail. Makes the wait feel like a letterpress scan + // instead of a dead spinner. + // --------------------------------------------------------------------------- + + const SHADER_VS = `attribute vec2 a_position; +attribute vec2 a_uv; +varying vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_position, 0.0, 1.0); +}`; + + const SHADER_FS = `precision highp float; +uniform sampler2D u_texture; +uniform float u_time; +uniform vec2 u_resolution; +uniform vec3 u_accent; +varying vec2 v_uv; + +// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at +// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean +// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below" +// failure that reversed-edge smoothstep would give). +float bandAt(float d, float leadW, float trailW) { + float above = smoothstep(-leadW, 0.0, d); + float below = 1.0 - smoothstep(0.0, trailW, d); + return above * below; +} + +void main() { + vec2 uv = v_uv; + // Roller sweeps top-to-bottom with small overshoot so each cycle enters + // and exits the element cleanly. + float phase = fract(u_time / 3.4); + float y = phase * 1.25 - 0.12; + float band = bandAt(uv.y - y, 0.05, 0.32); + + // Halftone cell grid (fixed ~10 px pitch). + float cellPx = 10.0; + vec2 gridUv = uv * u_resolution / cellPx; + vec2 cellId = floor(gridUv); + vec2 cellUv = fract(gridUv) - 0.5; + vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution; + vec3 cellImg = texture2D(u_texture, sampleCenter).rgb; + float luma = dot(cellImg, vec3(0.299, 0.587, 0.114)); + // Darker cells → bigger magenta dots (classic risograph halftone curve). + float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56; + float dotMask = smoothstep(radius + 0.06, radius, length(cellUv)); + vec3 paper = vec3(0.975, 0.965, 0.955); + vec3 dotLayer = mix(paper, u_accent, dotMask); + + // Blend the halftone layer in where the roller is passing; leave the + // element pristine elsewhere. + vec3 base = texture2D(u_texture, uv).rgb; + gl_FragColor = vec4(mix(base, dotLayer, band), 1.0); +}`; + + // Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350)) + const SHADER_ACCENT = [0.82, 0.16, 0.47]; + let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + + function compileShader(gl, type, source) { + const sh = gl.createShader(type); + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(sh); + gl.deleteShader(sh); + throw new Error('shader compile failed: ' + info); + } + return sh; + } + + function positionShaderOverlay() { + if (!shaderState || !selectedElement) return; + const r = selectedElement.getBoundingClientRect(); + Object.assign(shaderState.canvas.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + } + + function hideShaderOverlay() { + if (!shaderState) return; + if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); + if (shaderState.canvas) shaderState.canvas.remove(); + const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + shaderState = null; + } + + async function showShaderOverlay(el, blob, rect) { + hideShaderOverlay(); + if (!blob || !el) return; + const canvas = document.createElement('canvas'); + canvas.id = PREFIX + '-shader'; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + Object.assign(canvas.style, { + position: 'fixed', + top: rect.top + 'px', left: rect.left + 'px', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', + zIndex: Z.bar - 1, + }); + document.body.appendChild(canvas); + + const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) + || canvas.getContext('experimental-webgl'); + if (!gl) { + // WebGL unavailable — fall back to a plain overlay so the user + // still sees something meaningful during generation. + canvas.remove(); + const img = document.createElement('img'); + img.src = URL.createObjectURL(blob); + img.id = PREFIX + '-shader'; + Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' }); + document.body.appendChild(img); + shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 }; + return; + } + + let program, texture; + try { + const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS); + program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error('program link failed: ' + gl.getProgramInfoLog(program)); + } + // Full-screen quad + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + -1, -1, 0, 1, + 1, -1, 1, 1, + -1, 1, 0, 0, + -1, 1, 0, 0, + 1, -1, 1, 1, + 1, 1, 1, 0, + ]), gl.STATIC_DRAW); + const posLoc = gl.getAttribLocation(program, 'a_position'); + const uvLoc = gl.getAttribLocation(program, 'a_uv'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(uvLoc); + gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8); + } catch (err) { + console.warn('[impeccable] shader setup failed:', err); + canvas.remove(); + return; + } + + // Upload the screenshot as a texture + let bitmap; + try { + bitmap = await createImageBitmap(blob); + } catch { + // Safari fallback: go via a regular Image + const imgUrl = URL.createObjectURL(blob); + const img = new Image(); + img.src = imgUrl; + await new Promise((r, rej) => { img.onload = r; img.onerror = rej; }); + bitmap = img; + URL.revokeObjectURL(imgUrl); + } + texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + if (bitmap.close) bitmap.close(); + + const uTime = gl.getUniformLocation(program, 'u_time'); + const uRes = gl.getUniformLocation(program, 'u_resolution'); + const uAccent = gl.getUniformLocation(program, 'u_accent'); + const uTex = gl.getUniformLocation(program, 'u_texture'); + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; + function frame() { + if (!shaderState) return; + const elapsed = (performance.now() - shaderState.startTime) / 1000; + const t = shaderState.reduced ? 0.0 : elapsed; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.useProgram(program); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.uniform1i(uTex, 0); + gl.uniform1f(uTime, t); + gl.uniform2f(uRes, canvas.width, canvas.height); + gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]); + gl.drawArrays(gl.TRIANGLES, 0, 6); + shaderState.rafId = requestAnimationFrame(frame); + } + frame(); } function handleAccept() { @@ -2713,6 +3600,7 @@ function init() { initHighlight(); + initAnnotOverlay(); initBar(); initActionPicker(); initGlobalBar(); diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs index 109aa768e..97163b255 100644 --- a/.kiro/skills/impeccable/scripts/live-server.mjs +++ b/.kiro/skills/impeccable/scripts/live-server.mjs @@ -18,7 +18,6 @@ import { randomUUID } from 'node:crypto'; import { spawn, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; @@ -56,8 +55,13 @@ const state = { pendingEvents: [], // browser events waiting for agent poll pendingPolls: [], // agent poll callbacks waiting for browser events exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots }; +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + function enqueueEvent(event) { if (state.pendingPolls.length > 0) { state.pendingPolls.shift()(event); @@ -134,6 +138,10 @@ function validateEvent(msg) { if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; return null; case 'accept': if (!msg.id) return 'accept: missing id'; @@ -175,6 +183,83 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { return; } + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + // --- Health --- if (p === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -250,10 +335,11 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } const absPath = path.resolve(process.cwd(), filePath); if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } - try { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(fs.readFileSync(absPath, 'utf-8')); - } catch { res.writeHead(404); res.end('File not found'); } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); return; } @@ -411,6 +497,9 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } for (const res of state.sseClients) { try { res.end(); } catch {} } state.sseClients.clear(); for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); @@ -442,12 +531,14 @@ Options: --help Show this help Endpoints: - /live.js Browser script (element picker + variant cycling) - /detect.js Detection overlay (backwards compatible) - /events SSE stream (server→browser) + POST (browser→server) - /poll Long-poll for agent CLI - /source Raw source file reader (no-HMR fallback) - /health Health check`); + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); process.exit(0); } @@ -531,6 +622,12 @@ try { state.token = randomUUID(); const portArg = args.find(a => a.startsWith('--port=')); state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); const { detectScript, liveScript } = loadBrowserScripts(); const liveScriptWithToken = diff --git a/.kiro/skills/impeccable/scripts/modern-screenshot.umd.js b/.kiro/skills/impeccable/scripts/modern-screenshot.umd.js new file mode 100644 index 000000000..a9c5208f6 --- /dev/null +++ b/.kiro/skills/impeccable/scripts/modern-screenshot.umd.js @@ -0,0 +1,14 @@ +(function(y,v){typeof exports=="object"&&typeof module!="undefined"?v(exports):typeof define=="function"&&define.amd?define(["exports"],v):(y=typeof globalThis!="undefined"?globalThis:y||self,v(y.modernScreenshot={}))})(this,function(y){"use strict";var rr=Object.defineProperty,nr=Object.defineProperties;var or=Object.getOwnPropertyDescriptors;var Z=Object.getOwnPropertySymbols;var xe=Object.prototype.hasOwnProperty,Me=Object.prototype.propertyIsEnumerable;var Oe=Math.pow,Le=(y,v,N)=>v in y?rr(y,v,{enumerable:!0,configurable:!0,writable:!0,value:N}):y[v]=N,D=(y,v)=>{for(var N in v||(v={}))xe.call(v,N)&&Le(y,N,v[N]);if(Z)for(var N of Z(v))Me.call(v,N)&&Le(y,N,v[N]);return y},M=(y,v)=>nr(y,or(v));var je=(y,v)=>{var N={};for(var R in y)xe.call(y,R)&&v.indexOf(R)<0&&(N[R]=y[R]);if(y!=null&&Z)for(var R of Z(y))v.indexOf(R)<0&&Me.call(y,R)&&(N[R]=y[R]);return N};var C=(y,v,N)=>new Promise((R,O)=>{var X=P=>{try{q(N.next(P))}catch(W){O(W)}},j=P=>{try{q(N.throw(P))}catch(W){O(W)}},q=P=>P.done?R(P.value):Promise.resolve(P.value).then(X,j);q((N=N.apply(y,v)).next())});var Be;function v(e,t){return e[13]=1,e[14]=t>>8,e[15]=t&255,e[16]=t>>8,e[17]=t&255,e}const N=112,R=72,O=89,X=115;let j;function q(){const e=new Int32Array(256);for(let t=0;t<256;t++){let r=t;for(let n=0;n<8;n++)r=r&1?3988292384^r>>>1:r>>>1;e[t]=r}return e}function P(e){let t=-1;j||(j=q());for(let r=0;r>>8;return t^-1}function W(e){const t=e.length-1;for(let r=t;r>=4;r--)if(e[r-4]===9&&e[r-3]===N&&e[r-2]===R&&e[r-1]===O&&e[r]===X)return r-3;return 0}function ae(e,t,r=!1){const n=new Uint8Array(13);t*=39.3701,n[0]=N,n[1]=R,n[2]=O,n[3]=X,n[4]=t>>>24,n[5]=t>>>16,n[6]=t>>>8,n[7]=t&255,n[8]=n[4],n[9]=n[5],n[10]=n[6],n[11]=n[7],n[12]=1;const i=P(n),a=new Uint8Array(4);if(a[0]=i>>>24,a[1]=i>>>16,a[2]=i>>>8,a[3]=i&255,r){const s=W(e);return e.set(n,s),e.set(a,s+13),e}else{const s=new Uint8Array(4);s[0]=0,s[1]=0,s[2]=0,s[3]=9;const o=new Uint8Array(54);return o.set(e,0),o.set(s,33),o.set(n,37),o.set(a,50),o}}const qe="AAlwSFlz",We="AAAJcEhZ",He="AAAACXBI";function Ve(e){let t=e.indexOf(qe);return t===-1&&(t=e.indexOf(We)),t===-1&&(t=e.indexOf(He)),t}const se="[modern-screenshot]",U=typeof window!="undefined",ze=U&&"Worker"in window,ie=U&&"atob"in window,Xe=U&&"btoa"in window,ee=U?(Be=window.navigator)==null?void 0:Be.userAgent:"",le=ee.includes("Chrome"),G=ee.includes("AppleWebKit")&&!le,te=ee.includes("Firefox"),Ge=e=>e&&"__CONTEXT__"in e,Ye=e=>e.constructor.name==="CSSFontFaceRule",Je=e=>e.constructor.name==="CSSImportRule",Ke=e=>e.constructor.name==="CSSLayerBlockRule",I=e=>e.nodeType===1,H=e=>typeof e.className=="object",ce=e=>e.tagName==="image",Qe=e=>e.tagName==="use",V=e=>I(e)&&typeof e.style!="undefined"&&!H(e),Ze=e=>e.nodeType===8,et=e=>e.nodeType===3,$=e=>e.tagName==="IMG",Y=e=>e.tagName==="VIDEO",tt=e=>e.tagName==="CANVAS",rt=e=>e.tagName==="TEXTAREA",nt=e=>e.tagName==="INPUT",ot=e=>e.tagName==="STYLE",at=e=>e.tagName==="SCRIPT",st=e=>e.tagName==="SELECT",it=e=>e.tagName==="SLOT",lt=e=>e.tagName==="IFRAME",ct=(...e)=>console.warn(se,...e);function ut(e){var r;const t=(r=e==null?void 0:e.createElement)==null?void 0:r.call(e,"canvas");return t&&(t.height=t.width=1),!!t&&"toDataURL"in t&&!!t.toDataURL("image/webp").includes("image/webp")}const re=e=>e.startsWith("data:");function ue(e,t){if(e.match(/^[a-z]+:\/\//i))return e;if(U&&e.match(/^\/\//))return window.location.protocol+e;if(e.match(/^[a-z]+:/i)||!U)return e;const r=J().implementation.createHTMLDocument(),n=r.createElement("base"),i=r.createElement("a");return r.head.appendChild(n),r.body.appendChild(i),t&&(n.href=t),i.href=e,i.href}function J(e){var t;return(t=e&&I(e)?e==null?void 0:e.ownerDocument:e)!=null?t:window.document}const K="http://www.w3.org/2000/svg";function fe(e,t,r){const n=J(r).createElementNS(K,"svg");return n.setAttributeNS(null,"width",e.toString()),n.setAttributeNS(null,"height",t.toString()),n.setAttributeNS(null,"viewBox",`0 0 ${e} ${t}`),n}function de(e,t){let r=new XMLSerializer().serializeToString(e);return t&&(r=r.replace(/[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu,"")),`data:image/svg+xml;charset=utf-8,${encodeURIComponent(r)}`}function ft(e,t="image/png",r=1){return C(this,null,function*(){try{return yield new Promise((n,i)=>{e.toBlob(a=>{a?n(a):i(new Error("Blob is null"))},t,r)})}catch(n){if(ie)return dt(e.toDataURL(t,r));throw n}})}function dt(e){var o,c;const[t,r]=e.split(","),n=(c=(o=t.match(/data:(.+);/))==null?void 0:o[1])!=null?c:void 0,i=window.atob(r),a=i.length,s=new Uint8Array(a);for(let u=0;u{const i=new FileReader;i.onload=()=>r(i.result),i.onerror=()=>n(i.error),i.onabort=()=>n(new Error(`Failed read blob to ${t}`)),t==="dataUrl"?i.readAsDataURL(e):t==="arrayBuffer"&&i.readAsArrayBuffer(e)})}const gt=e=>ge(e,"dataUrl"),mt=e=>ge(e,"arrayBuffer");function _(e,t){const r=J(t).createElement("img");return r.decoding="sync",r.loading="eager",r.src=e,r}function L(e,t){return new Promise(r=>{const{timeout:n,ownerDocument:i,onError:a,onWarn:s}=t!=null?t:{},o=typeof e=="string"?_(e,J(i)):e;let c=null,u=null;function l(){r(o),c&&clearTimeout(c),u==null||u()}if(n&&(c=setTimeout(l,n)),Y(o)){const d=o.currentSrc||o.src;if(!d)return o.poster?L(o.poster,t).then(r):l();if(o.readyState>=2)return l();const m=l,f=h=>{s==null||s("Failed video load",d,h),a==null||a(h),l()};u=()=>{o.removeEventListener("loadeddata",m),o.removeEventListener("error",f)},o.addEventListener("loadeddata",m,{once:!0}),o.addEventListener("error",f,{once:!0})}else{const d=ce(o)?o.href.baseVal:o.currentSrc||o.src;if(!d)return l();const m=()=>C(this,null,function*(){if($(o)&&"decode"in o)try{yield o.decode()}catch(h){s==null||s("Failed to decode image, trying to render anyway",o.dataset.originalSrc||d,h)}l()}),f=h=>{s==null||s("Failed image load",o.dataset.originalSrc||d,h),l()};if($(o)&&o.complete)return m();u=()=>{o.removeEventListener("load",m),o.removeEventListener("error",f)},o.addEventListener("load",m,{once:!0}),o.addEventListener("error",f,{once:!0})}})}function me(e,t){return C(this,null,function*(){V(e)&&($(e)||Y(e)?yield L(e,t):yield Promise.all(["img","video"].flatMap(r=>Array.from(e.querySelectorAll(r)).map(n=>L(n,t)))))})}const he=function(){let t=0;const r=()=>`0000${(Math.random()*Oe(36,4)<<0).toString(36)}`.slice(-4);return()=>(t+=1,`u${r()}${t}`)}();function we(e){return e==null?void 0:e.split(",").map(t=>t.trim().replace(/"|'/g,"").toLowerCase()).filter(Boolean)}let pe=0;function ht(e){const t=`${se}[#${pe}]`;return pe++,{time:r=>e&&console.time(`${t} ${r}`),timeEnd:r=>e&&console.timeEnd(`${t} ${r}`),warn:(...r)=>e&&ct(...r)}}function wt(e){return{cache:e?"no-cache":"force-cache"}}function k(e,t){return C(this,null,function*(){return Ge(e)?e:ye(e,M(D({},t),{autoDestruct:!0}))})}function ye(e,t){return C(this,null,function*(){var f,h,g,p,E;const{scale:r=1,workerUrl:n,workerNumber:i=1}=t||{},a=!!(t!=null&&t.debug),s=(f=t==null?void 0:t.features)!=null?f:!0,o=(h=e.ownerDocument)!=null?h:U?window.document:void 0,c=(p=(g=e.ownerDocument)==null?void 0:g.defaultView)!=null?p:U?window:void 0,u=new Map,l=M(D({width:0,height:0,quality:1,type:"image/png",scale:r,backgroundColor:null,style:null,filter:null,maximumCanvasSize:0,timeout:3e4,progress:null,debug:a,fetch:D({requestInit:wt((E=t==null?void 0:t.fetch)==null?void 0:E.bypassingCache),placeholderImage:"data:image/png;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",bypassingCache:!1},t==null?void 0:t.fetch),fetchFn:null,font:{},drawImageInterval:100,workerUrl:null,workerNumber:i,onCloneEachNode:null,onCloneNode:null,onEmbedNode:null,onCreateForeignObjectSvg:null,includeStyleProperties:null,autoDestruct:!1},t),{__CONTEXT__:!0,log:ht(a),node:e,ownerDocument:o,ownerWindow:c,dpi:r===1?null:96*r,svgStyleElement:be(o),svgDefsElement:o==null?void 0:o.createElementNS(K,"defs"),svgStyles:new Map,defaultComputedStyles:new Map,workers:[...Array.from({length:ze&&n&&i?i:0})].map(()=>{try{const b=new Worker(n);return b.onmessage=w=>C(this,null,function*(){var A,F,B,$e;const{url:S,result:T}=w.data;T?(F=(A=u.get(S))==null?void 0:A.resolve)==null||F.call(A,T):($e=(B=u.get(S))==null?void 0:B.reject)==null||$e.call(B,new Error(`Error receiving message from worker: ${S}`))}),b.onmessageerror=w=>{var T,A;const{url:S}=w.data;(A=(T=u.get(S))==null?void 0:T.reject)==null||A.call(T,new Error(`Error receiving message from worker: ${S}`))},b}catch(b){return l.log.warn("Failed to new Worker",b),null}}).filter(Boolean),fontFamilies:new Map,fontCssTexts:new Map,acceptOfImage:`${[ut(o)&&"image/webp","image/svg+xml","image/*","*/*"].filter(Boolean).join(",")};q=0.8`,requests:u,drawImageCount:0,tasks:[],features:s,isEnable:b=>{var w,S;return b==="restoreScrollPosition"?typeof s=="boolean"?!1:(w=s[b])!=null?w:!1:typeof s=="boolean"?s:(S=s[b])!=null?S:!0},shadowRoots:[]});l.log.time("wait until load"),yield me(e,{timeout:l.timeout,onWarn:l.log.warn}),l.log.timeEnd("wait until load");const{width:d,height:m}=pt(e,l);return l.width=d,l.height=m,l})}function be(e){if(!e)return;const t=e.createElement("style"),r=t.ownerDocument.createTextNode(` +.______background-clip--text { + background-clip: text; + -webkit-background-clip: text; +} +`);return t.appendChild(r),t}function pt(e,t){let{width:r,height:n}=t;if(I(e)&&(!r||!n)){const i=e.getBoundingClientRect();r=r||i.width||Number(e.getAttribute("width"))||0,n=n||i.height||Number(e.getAttribute("height"))||0}return{width:r,height:n}}function yt(e,t){return C(this,null,function*(){const{log:r,timeout:n,drawImageCount:i,drawImageInterval:a}=t;r.time("image to canvas");const s=yield L(e,{timeout:n,onWarn:t.log.warn}),{canvas:o,context2d:c}=bt(e.ownerDocument,t),u=()=>{try{c==null||c.drawImage(s,0,0,o.width,o.height)}catch(l){t.log.warn("Failed to drawImage",l)}};if(u(),t.isEnable("fixSvgXmlDecode"))for(let l=0;l{setTimeout(()=>{c==null||c.clearRect(0,0,o.width,o.height),u(),d()},l+a)});return t.drawImageCount=0,r.timeEnd("image to canvas"),o})}function bt(e,t){const{width:r,height:n,scale:i,backgroundColor:a,maximumCanvasSize:s}=t,o=e.createElement("canvas");o.width=Math.floor(r*i),o.height=Math.floor(n*i),o.style.width=`${r}px`,o.style.height=`${n}px`,s&&(o.width>s||o.height>s)&&(o.width>s&&o.height>s?o.width>o.height?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s):o.width>s?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s));const c=o.getContext("2d");return c&&a&&(c.fillStyle=a,c.fillRect(0,0,o.width,o.height)),{canvas:o,context2d:c}}function Se(e,t){if(e.ownerDocument)try{const a=e.toDataURL();if(a!=="data:,")return _(a,e.ownerDocument)}catch(a){t.log.warn("Failed to clone canvas",a)}const r=e.cloneNode(!1),n=e.getContext("2d"),i=r.getContext("2d");try{return n&&i&&i.putImageData(n.getImageData(0,0,e.width,e.height),0,0),r}catch(a){t.log.warn("Failed to clone canvas",a)}return r}function St(e,t){var r;try{if((r=e==null?void 0:e.contentDocument)!=null&&r.documentElement)return ne(e.contentDocument.documentElement,t)}catch(n){t.log.warn("Failed to clone iframe",n)}return e.cloneNode(!1)}function Et(e){const t=e.cloneNode(!1);return e.currentSrc&&e.currentSrc!==e.src&&(t.src=e.currentSrc,t.srcset=""),t.loading==="lazy"&&(t.loading="eager"),t}function Ct(e,t){return C(this,null,function*(){if(e.ownerDocument&&!e.currentSrc&&e.poster)return _(e.poster,e.ownerDocument);const r=e.cloneNode(!1);r.crossOrigin="anonymous",e.currentSrc&&e.currentSrc!==e.src&&(r.src=e.currentSrc);const n=r.ownerDocument;if(n){let i=!0;if(yield L(r,{onError:()=>i=!1,onWarn:t.log.warn}),!i)return e.poster?_(e.poster,e.ownerDocument):r;r.currentTime=e.currentTime,yield new Promise(s=>{r.addEventListener("seeked",s,{once:!0})});const a=n.createElement("canvas");a.width=e.offsetWidth,a.height=e.offsetHeight;try{const s=a.getContext("2d");s&&s.drawImage(r,0,0,a.width,a.height)}catch(s){return t.log.warn("Failed to clone video",s),e.poster?_(e.poster,e.ownerDocument):r}return Se(a,t)}return r})}function Tt(e,t){return tt(e)?Se(e,t):lt(e)?St(e,t):$(e)?Et(e):Y(e)?Ct(e,t):e.cloneNode(!1)}function vt(e){let t=e.sandbox;if(!t){const{ownerDocument:r}=e;try{r&&(t=r.createElement("iframe"),t.id=`__SANDBOX__${he()}`,t.width="0",t.height="0",t.style.visibility="hidden",t.style.position="fixed",r.body.appendChild(t),t.srcdoc='',e.sandbox=t)}catch(n){e.log.warn("Failed to getSandBox",n)}}return t}const At=["width","height","-webkit-text-fill-color"],Nt=["stroke","fill"];function Ee(e,t,r){const{defaultComputedStyles:n}=r,i=e.nodeName.toLowerCase(),a=H(e)&&i!=="svg",s=a?Nt.map(g=>[g,e.getAttribute(g)]).filter(([,g])=>g!==null):[],o=[a&&"svg",i,s.map((g,p)=>`${g}=${p}`).join(","),t].filter(Boolean).join(":");if(n.has(o))return n.get(o);const c=vt(r),u=c==null?void 0:c.contentWindow;if(!u)return new Map;const l=u==null?void 0:u.document;let d,m;a?(d=l.createElementNS(K,"svg"),m=d.ownerDocument.createElementNS(d.namespaceURI,i),s.forEach(([g,p])=>{m.setAttributeNS(null,g,p)}),d.appendChild(m)):d=m=l.createElement(i),m.textContent=" ",l.body.appendChild(d);const f=u.getComputedStyle(m,t),h=new Map;for(let g=f.length,p=0;pn.set(d,l));function s(c){const u=e.getPropertyValue(c),l=e.getPropertyPriority(c),d=c.lastIndexOf("-"),m=d>-1?c.substring(0,d):void 0;if(m){let f=a.get(m);f||(f=new Map,a.set(m,f)),f.set(c,[u,l])}t.get(c)===u&&!l||(m?i.push(m):n.set(c,[u,l]))}return n}function Rt(e,t,r,n){var d,m,f,h;const{ownerWindow:i,includeStyleProperties:a,currentParentNodeStyle:s}=n,o=t.style,c=i.getComputedStyle(e),u=Ee(e,null,n);s==null||s.forEach((g,p)=>{u.delete(p)});const l=Ce(c,u,a);l.delete("transition-property"),l.delete("all"),l.delete("d"),l.delete("content"),r&&(l.delete("position"),l.delete("margin-top"),l.delete("margin-right"),l.delete("margin-bottom"),l.delete("margin-left"),l.delete("margin-block-start"),l.delete("margin-block-end"),l.delete("margin-inline-start"),l.delete("margin-inline-end"),l.set("box-sizing",["border-box",""])),((d=l.get("background-clip"))==null?void 0:d[0])==="text"&&t.classList.add("______background-clip--text"),le&&(l.has("font-kerning")||l.set("font-kerning",["normal",""]),(((m=l.get("overflow-x"))==null?void 0:m[0])==="hidden"||((f=l.get("overflow-y"))==null?void 0:f[0])==="hidden")&&((h=l.get("text-overflow"))==null?void 0:h[0])==="ellipsis"&&e.scrollWidth===e.clientWidth&&l.set("text-overflow",["clip",""]));for(let g=o.length,p=0;p{o.setProperty(E,g,p)}),l}function It(e,t){(rt(e)||nt(e)||st(e))&&t.setAttribute("value",e.value)}const kt=["::before","::after"],Dt=["::-webkit-scrollbar","::-webkit-scrollbar-button","::-webkit-scrollbar-thumb","::-webkit-scrollbar-track","::-webkit-scrollbar-track-piece","::-webkit-scrollbar-corner","::-webkit-resizer"];function Pt(e,t,r,n,i){const{ownerWindow:a,svgStyleElement:s,svgStyles:o,currentNodeStyle:c}=n;if(!s||!a)return;function u(l){var w;const d=a.getComputedStyle(e,l);let m=d.getPropertyValue("content");if(!m||m==="none")return;i==null||i(m),m=m.replace(/(')|(")|(counter\(.+\))/g,"");const f=[he()],h=Ee(e,l,n);c==null||c.forEach((S,T)=>{h.delete(T)});const g=Ce(d,h,n.includeStyleProperties);g.delete("content"),g.delete("-webkit-locale"),((w=g.get("background-clip"))==null?void 0:w[0])==="text"&&t.classList.add("______background-clip--text");const p=[`content: '${m}';`];if(g.forEach(([S,T],A)=>{p.push(`${A}: ${S}${T?" !important":""};`)}),p.length===1)return;try{t.className=[t.className,...f].join(" ")}catch(S){n.log.warn("Failed to copyPseudoClass",S);return}const E=p.join(` + `);let b=o.get(E);b||(b=[],o.set(E,b)),b.push(`.${f[0]}${l}`)}kt.forEach(u),r&&Dt.forEach(u)}const Te=new Set(["symbol"]);function ve(e,t,r,n,i){return C(this,null,function*(){if(I(r)&&(ot(r)||at(r))||n.filter&&!n.filter(r))return;Te.has(t.nodeName)||Te.has(r.nodeName)?n.currentParentNodeStyle=void 0:n.currentParentNodeStyle=n.currentNodeStyle;const a=yield ne(r,n,!1,i);n.isEnable("restoreScrollPosition")&&Ut(e,a),t.appendChild(a)})}function Ae(e,t,r,n){return C(this,null,function*(){var a;let i=e.firstChild;I(e)&&e.shadowRoot&&(i=(a=e.shadowRoot)==null?void 0:a.firstChild,r.shadowRoots.push(e.shadowRoot));for(let s=i;s;s=s.nextSibling)if(!Ze(s))if(I(s)&&it(s)&&typeof s.assignedNodes=="function"){const o=s.assignedNodes();for(let c=0;ce.clientHeight||e.scrollWidth>e.clientWidth)}const p=(d=h.get("text-transform"))==null?void 0:d[0],E=we((m=h.get("font-family"))==null?void 0:m[0]),b=E?w=>{p==="uppercase"?w=w.toUpperCase():p==="lowercase"?w=w.toLowerCase():p==="capitalize"&&(w=w[0].toUpperCase()+w.substring(1)),E.forEach(S=>{let T=s.get(S);T||s.set(S,T=new Set),w.split("").forEach(A=>T.add(A))})}:void 0;return Pt(e,f,g,t,b),It(e,f),Y(e)||(yield Ae(e,f,t,b)),yield o==null?void 0:o(f),f}const c=e.cloneNode(!1);return yield Ae(e,c,t),yield o==null?void 0:o(c),c})}function Ne(e){if(e.ownerDocument=void 0,e.ownerWindow=void 0,e.svgStyleElement=void 0,e.svgDefsElement=void 0,e.svgStyles.clear(),e.defaultComputedStyles.clear(),e.sandbox){try{e.sandbox.remove()}catch(t){e.log.warn("Failed to destroyContext",t)}e.sandbox=void 0}e.workers=[],e.fontFamilies.clear(),e.fontCssTexts.clear(),e.requests.clear(),e.tasks=[],e.shadowRoots=[]}function Bt(e){const o=e,{url:t,timeout:r,responseType:n}=o,i=je(o,["url","timeout","responseType"]),a=new AbortController,s=r?setTimeout(()=>a.abort(),r):void 0;return fetch(t,D({signal:a.signal},i)).then(c=>{if(!c.ok)throw new Error("Failed fetch, not 2xx response",{cause:c});switch(n){case"arrayBuffer":return c.arrayBuffer();case"dataUrl":return c.blob().then(gt);case"text":default:return c.text()}}).finally(()=>clearTimeout(s))}function z(e,t){const{url:r,requestType:n="text",responseType:i="text",imageDom:a}=t;let s=r;const{timeout:o,acceptOfImage:c,requests:u,fetchFn:l,fetch:{requestInit:d,bypassingCache:m,placeholderImage:f},font:h,workers:g,fontFamilies:p}=e;n==="image"&&(G||te)&&e.drawImageCount++;let E=u.get(r);if(!E){m&&m instanceof RegExp&&m.test(s)&&(s+=(/\?/.test(s)?"&":"?")+new Date().getTime());const b=n.startsWith("font")&&h&&h.minify,w=new Set;b&&n.split(";")[1].split(",").forEach(F=>{p.has(F)&&p.get(F).forEach(B=>w.add(B))});const S=b&&w.size,T=D({url:s,timeout:o,responseType:S?"arrayBuffer":i,headers:n==="image"?{accept:c}:void 0},d);E={type:n,resolve:void 0,reject:void 0,response:null},E.response=C(this,null,function*(){if(l&&n==="image"){const A=yield l(r);if(A)return A}return!G&&r.startsWith("http")&&g.length?new Promise((A,F)=>{g[u.size&g.length-1].postMessage(D({rawUrl:r},T)),E.resolve=A,E.reject=F}):Bt(T)}).catch(A=>{if(u.delete(r),n==="image"&&f)return e.log.warn("Failed to fetch image base64, trying to use placeholder image",s),typeof f=="string"?f:f(a);throw A}),u.set(r,E)}return E.response}function Re(e,t,r,n){return C(this,null,function*(){if(!Ie(e))return e;for(const[i,a]of $t(e,t))try{const s=yield z(r,{url:a,requestType:n?"image":"text",responseType:"dataUrl"});e=e.replace(Lt(i),`$1${s}$3`)}catch(s){r.log.warn("Failed to fetch css data url",i,s)}return e})}function Ie(e){return/url\((['"]?)([^'"]+?)\1\)/.test(e)}const ke=/url\((['"]?)([^'"]+?)\1\)/g;function $t(e,t){const r=[];return e.replace(ke,(n,i,a)=>(r.push([a,ue(a,t)]),n)),r.filter(([n])=>!re(n))}function Lt(e){const t=e.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${t})(['"]?\\))`,"g")}const xt=["background-image","border-image-source","-webkit-border-image","-webkit-mask-image","list-style-image"];function Mt(e,t){return xt.map(r=>{const n=e.getPropertyValue(r);return!n||n==="none"?null:((G||te)&&t.drawImageCount++,Re(n,null,t,!0).then(i=>{!i||n===i||e.setProperty(r,i,e.getPropertyPriority(r))}))}).filter(Boolean)}function Ot(e,t){if($(e)){const r=e.currentSrc||e.src;if(!re(r))return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.srcset="",e.dataset.originalSrc=r,e.src=n||"")})];(G||te)&&t.drawImageCount++}else if(H(e)&&!re(e.href.baseVal)){const r=e.href.baseVal;return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.dataset.originalSrc=r,e.href.baseVal=n||"")})]}return[]}function jt(e,t){var o;const{ownerDocument:r,svgDefsElement:n}=t,i=(o=e.getAttribute("href"))!=null?o:e.getAttribute("xlink:href");if(!i)return[];const[a,s]=i.split("#");if(s){const c=`#${s}`,u=t.shadowRoots.reduce((l,d)=>l!=null?l:d.querySelector(`svg ${c}`),r==null?void 0:r.querySelector(`svg ${c}`));if(a&&e.setAttribute("href",c),n!=null&&n.querySelector(c))return[];if(u)return n==null||n.appendChild(u.cloneNode(!0)),[];if(a)return[z(t,{url:a,responseType:"text"}).then(l=>{n==null||n.insertAdjacentHTML("beforeend",l)})]}return[]}function De(e,t){const{tasks:r}=t;I(e)&&(($(e)||ce(e))&&r.push(...Ot(e,t)),Qe(e)&&r.push(...jt(e,t))),V(e)&&r.push(...Mt(e.style,t)),e.childNodes.forEach(n=>{De(n,t)})}function qt(e,t){return C(this,null,function*(){const{ownerDocument:r,svgStyleElement:n,fontFamilies:i,fontCssTexts:a,tasks:s,font:o}=t;if(!(!r||!n||!i.size))if(o&&o.cssText){const c=Ue(o.cssText,t);n.appendChild(r.createTextNode(`${c} +`))}else{const c=Array.from(r.styleSheets).filter(f=>{try{return"cssRules"in f&&!!f.cssRules.length}catch(h){return t.log.warn(`Error while reading CSS rules from ${f.href}`,h),!1}}),u=r.implementation.createHTMLDocument(""),l=u.createElement("style");u.head.appendChild(l);const d=l.sheet;yield Promise.all(c.flatMap(f=>Array.from(f.cssRules).map(h=>C(this,null,function*(){if(Je(h)){const g=h.href;let p="";try{p=yield z(t,{url:g,requestType:"text",responseType:"text"})}catch(b){t.log.warn(`Error fetch remote css import from ${g}`,b)}const E=p.replace(ke,(b,w,S)=>b.replace(S,ue(S,g)));for(const b of Ht(E))try{d.insertRule(b,d.cssRules.length)}catch(w){t.log.warn("Error inserting rule from remote css import",{rule:b,error:w})}}})))),d.cssRules.length&&c.push(d);const m=[];c.forEach(f=>{oe(f.cssRules,m)}),m.filter(f=>{var h;return Ye(f)&&Ie(f.style.getPropertyValue("src"))&&((h=we(f.style.getPropertyValue("font-family")))==null?void 0:h.some(g=>i.has(g)))}).forEach(f=>{const h=f,g=a.get(h.cssText);g?n.appendChild(r.createTextNode(`${g} +`)):s.push(Re(h.cssText,h.parentStyleSheet?h.parentStyleSheet.href:null,t).then(p=>{p=Ue(p,t),a.set(h.cssText,p),n.appendChild(r.createTextNode(`${p} +`))}))})}})}const Wt=/(\/\*[\s\S]*?\*\/)/g,Pe=/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi;function Ht(e){if(e==null)return[];const t=[];let r=e.replace(Wt,"");for(;;){const a=Pe.exec(r);if(!a)break;t.push(a[0])}r=r.replace(Pe,"");const n=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,i=new RegExp("((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})","gi");for(;;){let a=n.exec(r);if(a)i.lastIndex=n.lastIndex;else if(a=i.exec(r),a)n.lastIndex=i.lastIndex;else break;t.push(a[0])}return t}const Vt=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,zt=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Ue(e,t){const{font:r}=t,n=r?r==null?void 0:r.preferredFormat:void 0;return n?e.replace(zt,i=>{for(;;){const[a,,s]=Vt.exec(i)||[];if(!s)return"";if(s===n)return`src: ${a};`}}):e}function oe(e,t=[]){for(const r of Array.from(e))Ke(r)?t.push(...oe(r.cssRules)):"cssRules"in r?oe(r.cssRules,t):t.push(r);return t}const Xt=/\bx?link:?href\s*=\s*["'](?!data:)[^"']+["']/i;function Gt(e){return Xt.test(e.innerHTML)}function _e(e,t){return C(this,null,function*(){const r=yield k(e,t);if(I(r.node)&&H(r.node)&&!Gt(r.node))return r.node;const{ownerDocument:n,log:i,tasks:a,svgStyleElement:s,svgDefsElement:o,svgStyles:c,font:u,progress:l,autoDestruct:d,onCloneNode:m,onEmbedNode:f,onCreateForeignObjectSvg:h}=r;i.time("clone node");const g=yield ne(r.node,r,!0);if(s&&n){let S="";c.forEach((T,A)=>{S+=`${T.join(`, +`)} { + ${A} +} +`}),s.appendChild(n.createTextNode(S))}i.timeEnd("clone node"),yield m==null?void 0:m(g),u!==!1&&I(g)&&(i.time("embed web font"),yield qt(g,r),i.timeEnd("embed web font")),i.time("embed node"),De(g,r);const p=a.length;let E=0;const b=()=>C(this,null,function*(){for(;;){const S=a.pop();if(!S)break;try{yield S}catch(T){r.log.warn("Failed to run task",T)}l==null||l(++E,p)}});l==null||l(E,p),yield Promise.all([...Array.from({length:4})].map(b)),i.timeEnd("embed node"),yield f==null?void 0:f(g);const w=Yt(g,r);return o&&w.insertBefore(o,w.children[0]),s&&w.insertBefore(s,w.children[0]),d&&Ne(r),yield h==null?void 0:h(w),w})}function Yt(e,t){const{width:r,height:n}=t,i=fe(r,n,e.ownerDocument),a=i.ownerDocument.createElementNS(i.namespaceURI,"foreignObject");return a.setAttributeNS(null,"x","0%"),a.setAttributeNS(null,"y","0%"),a.setAttributeNS(null,"width","100%"),a.setAttributeNS(null,"height","100%"),a.append(e),i.appendChild(a),i}function Q(e,t){return C(this,null,function*(){var s;const r=yield k(e,t),n=yield _e(r),i=de(n,r.isEnable("removeControlCharacter"));r.autoDestruct||(r.svgStyleElement=be(r.ownerDocument),r.svgDefsElement=(s=r.ownerDocument)==null?void 0:s.createElementNS(K,"defs"),r.svgStyles.clear());const a=_(i,n.ownerDocument);return yield yt(a,r)})}function Jt(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,type:i,quality:a,dpi:s}=r,o=yield Q(r);n.time("canvas to blob");const c=yield ft(o,i,a);if(["image/png","image/jpeg"].includes(i)&&s){const u=yield mt(c.slice(0,33));let l=new Uint8Array(u);return i==="image/png"?l=ae(l,s):i==="image/jpeg"&&(l=v(l,s)),n.timeEnd("canvas to blob"),new Blob([l,c.slice(33)],{type:i})}return n.timeEnd("canvas to blob"),c})}function x(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,quality:i,type:a,dpi:s}=r,o=yield Q(r);n.time("canvas to data url");let c=o.toDataURL(a,i);if(["image/png","image/jpeg"].includes(a)&&s&&ie&&Xe){const[u,l]=c.split(",");let d=0,m=!1;if(a==="image/png"){const w=Ve(l);w>=0?(d=Math.ceil((w+28)/3)*4,m=!0):d=33/3*4}else a==="image/jpeg"&&(d=18/3*4);const f=l.substring(0,d),h=l.substring(d),g=window.atob(f),p=new Uint8Array(g.length);for(let w=0;w mirroring selectedElement's bounding rect. Click (no + // drag) drops a comment pin; drag paints a magenta SVG stroke. All coords + // are stored in element-local CSS px so they survive scroll / resize and + // correlate directly with the captured PNG. + // --------------------------------------------------------------------------- + + const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click + const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it + let annotOverlayEl = null; + let annotSvgEl = null; + let annotPinsEl = null; + let annotClearChipEl = null; + let annotState = { comments: [], strokes: [] }; + let annotActive = false; + // `annotPointer` is either: + // { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin + // { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin + let annotPointer = null; + let annotEditing = null; // { idx, input, wrapEl } + let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete + + function initAnnotOverlay() { + annotOverlayEl = document.createElement('div'); + annotOverlayEl.id = PREFIX + '-annot'; + Object.assign(annotOverlayEl.style, { + position: 'fixed', top: '0', left: '0', width: '0', height: '0', + pointerEvents: 'auto', zIndex: Z.highlight + 2, + display: 'none', overflow: 'visible', + cursor: 'crosshair', touchAction: 'none', + }); + + annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + annotSvgEl.id = PREFIX + '-annot-svg'; + Object.assign(annotSvgEl.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', + // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via + // pointer-events=stroke so gaps still fall through to the overlay. + pointerEvents: 'none', overflow: 'visible', + }); + annotOverlayEl.appendChild(annotSvgEl); + + annotPinsEl = document.createElement('div'); + annotPinsEl.id = PREFIX + '-annot-pins'; + Object.assign(annotPinsEl.style, { + position: 'absolute', inset: '0', + pointerEvents: 'none', + }); + annotOverlayEl.appendChild(annotPinsEl); + + annotClearChipEl = document.createElement('div'); + annotClearChipEl.id = PREFIX + '-annot-clear'; + annotClearChipEl.dataset.annotClear = 'true'; + annotClearChipEl.textContent = 'Clear'; + Object.assign(annotClearChipEl.style, { + position: 'absolute', top: '8px', right: '8px', + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '10px', fontWeight: '500', + letterSpacing: '0.08em', textTransform: 'uppercase', + padding: '5px 12px', borderRadius: '999px', + cursor: 'pointer', pointerEvents: 'auto', + display: 'none', userSelect: 'none', + boxShadow: '0 1px 3px rgba(0,0,0,0.2)', + }); + annotOverlayEl.appendChild(annotClearChipEl); + + annotOverlayEl.addEventListener('pointerdown', onAnnotDown); + annotOverlayEl.addEventListener('pointermove', onAnnotMove); + annotOverlayEl.addEventListener('pointerup', onAnnotUp); + annotOverlayEl.addEventListener('pointercancel', onAnnotUp); + document.body.appendChild(annotOverlayEl); + } + + function updateClearChip() { + if (!annotClearChipEl) return; + const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0; + annotClearChipEl.style.display = hasAny ? 'block' : 'none'; + } + + function showAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + annotActive = true; + positionAnnotOverlay(el); + annotOverlayEl.style.display = 'block'; + } + + function hideAnnotOverlay() { + annotActive = false; + if (annotOverlayEl) annotOverlayEl.style.display = 'none'; + // Drop any in-progress edit without touching annotState — clearAnnotations + // (if the caller is exiting configure mode) handles state reset. + annotEditing = null; + } + + function positionAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + const r = el.getBoundingClientRect(); + Object.assign(annotOverlayEl.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height); + } + + function clearAnnotations() { + annotState.comments = []; + annotState.strokes = []; + if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + if (annotPinsEl) annotPinsEl.innerHTML = ''; + annotPointer = null; + annotEditing = null; + annotLastPinClick = { idx: -1, time: 0 }; + updateClearChip(); + } + + // Rebuild the SVG layer. Each stroke gets a wider invisible hit path + // beneath the visible magenta path so clicks register on thin lines. + function redrawStrokes() { + while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + annotState.strokes.forEach((s, idx) => { + const d = pointsToPath(s.points); + const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + hit.setAttribute('d', d); + hit.setAttribute('stroke', 'transparent'); + hit.setAttribute('stroke-width', '16'); + hit.setAttribute('stroke-linecap', 'round'); + hit.setAttribute('stroke-linejoin', 'round'); + hit.setAttribute('fill', 'none'); + hit.setAttribute('pointer-events', 'stroke'); + hit.style.cursor = 'pointer'; + hit.dataset.annotStroke = String(idx); + annotSvgEl.appendChild(hit); + const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + visible.setAttribute('d', d); + visible.setAttribute('stroke', C.brand); + visible.setAttribute('stroke-width', '3'); + visible.setAttribute('stroke-linecap', 'round'); + visible.setAttribute('stroke-linejoin', 'round'); + visible.setAttribute('fill', 'none'); + visible.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(visible); + }); + updateClearChip(); + } + + function localCoords(e) { + const rect = annotOverlayEl.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + function onAnnotDown(e) { + if (!annotActive) return; + + // 1) Clear chip → wipe all annotations + if (e.target.closest?.('[data-annot-clear]')) { + if (annotEditing) annotEditing = null; + clearAnnotations(); + renderAllPins(); + redrawStrokes(); + e.stopPropagation(); e.preventDefault(); + return; + } + + // 2) Stroke hit path → delete that stroke + const strokeHit = e.target.closest?.('[data-annot-stroke]'); + if (strokeHit) { + const idx = parseInt(strokeHit.dataset.annotStroke, 10); + if (Number.isInteger(idx)) { + annotState.strokes.splice(idx, 1); + redrawStrokes(); + } + e.stopPropagation(); e.preventDefault(); + return; + } + + // 3) Pin → drag, edit, or delete-on-double-click + const pinWrap = e.target.closest?.('[data-annot-pin]'); + if (pinWrap) { + const idx = parseInt(pinWrap.dataset.annotPin, 10); + if (!Number.isInteger(idx)) return; + // Double-click (two pointerdowns on the same pin within window) → delete. + const now = Date.now(); + if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) { + if (annotEditing && annotEditing.idx === idx) annotEditing = null; + annotState.comments.splice(idx, 1); + annotLastPinClick = { idx: -1, time: 0 }; + renderAllPins(); + e.stopPropagation(); e.preventDefault(); + return; + } + annotLastPinClick = { idx, time: now }; + // If editing a different pin, commit that edit before starting here. + if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin(); + // If already editing THIS pin and the user clicked the dot, let the + // input keep focus (don't start a drag — the click wasn't meant as one). + if (annotEditing && annotEditing.idx === idx) return; + const p = localCoords(e); + const pin = annotState.comments[idx]; + annotPointer = { + kind: 'pin', idx, + startPointer: p, + startPin: { x: pin.x, y: pin.y }, + moved: false, + }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + return; + } + + // 4) Empty area → commit any open edit, then start new annotation + if (annotEditing) { + finalizeEditingPin(); + e.stopPropagation(); e.preventDefault(); + return; + } + const p = localCoords(e); + annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + } + + function onAnnotMove(e) { + if (!annotActive || !annotPointer) return; + const p = localCoords(e); + + if (annotPointer.kind === 'pin') { + const dx = p.x - annotPointer.startPointer.x; + const dy = p.y - annotPointer.startPointer.y; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + } + const pin = annotState.comments[annotPointer.idx]; + if (!pin) { annotPointer = null; return; } + pin.x = annotPointer.startPin.x + dx; + pin.y = annotPointer.startPin.y + dy; + renderAllPins(); + e.stopPropagation(); + return; + } + + // kind === 'new' + const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + strokeEl.setAttribute('stroke', C.brand); + strokeEl.setAttribute('stroke-width', '3'); + strokeEl.setAttribute('stroke-linecap', 'round'); + strokeEl.setAttribute('stroke-linejoin', 'round'); + strokeEl.setAttribute('fill', 'none'); + strokeEl.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(strokeEl); + annotPointer.strokeEl = strokeEl; + annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]]; + } + annotPointer.strokePoints.push([p.x, p.y]); + annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints)); + e.stopPropagation(); + } + + function onAnnotUp(e) { + if (!annotActive || !annotPointer) return; + + if (annotPointer.kind === 'pin') { + const wasDrag = annotPointer.moved; + const idx = annotPointer.idx; + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + if (wasDrag) { + // A drag is an intentional reposition; a follow-up click shouldn't be + // interpreted as a double-click-to-delete. + annotLastPinClick = { idx: -1, time: 0 }; + } else { + beginEditPin(idx); + } + e.stopPropagation(); + return; + } + + // kind === 'new' + const wasDrag = annotPointer.moved; + if (wasDrag) { + annotState.strokes.push({ points: annotPointer.strokePoints }); + // Swap the temporary preview SVG path for the full render with hit paths. + redrawStrokes(); + } else { + const idx = annotState.comments.length; + annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' }); + renderAllPins(); + beginEditPin(idx); + } + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + e.stopPropagation(); + } + + function pointsToPath(points) { + if (!points || points.length === 0) return ''; + let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1); + for (let i = 1; i < points.length; i++) { + d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1); + } + return d; + } + + function renderAllPins() { + annotPinsEl.innerHTML = ''; + annotState.comments.forEach((c, idx) => { + annotPinsEl.appendChild(buildPinElement(c, idx)); + }); + updateClearChip(); + } + + function buildPinElement(comment, idx) { + const interactive = idx >= 0; + const wrap = document.createElement('div'); + if (interactive) wrap.dataset.annotPin = String(idx); + Object.assign(wrap.style, { + position: 'absolute', + left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px', + pointerEvents: interactive ? 'auto' : 'none', + display: 'flex', alignItems: 'flex-start', gap: '6px', + cursor: interactive ? 'grab' : 'default', + touchAction: 'none', + }); + const dot = document.createElement('div'); + Object.assign(dot.style, { + width: '14px', height: '14px', borderRadius: '50%', + background: C.brand, border: '2px solid ' + C.white, + boxShadow: '0 1px 3px rgba(0,0,0,0.25)', + flexShrink: '0', + }); + wrap.appendChild(dot); + + if (comment.text) { + const bubble = document.createElement('div'); + bubble.textContent = comment.text; + Object.assign(bubble.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + marginTop: '-2px', maxWidth: '220px', + pointerEvents: 'none', whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }); + wrap.appendChild(bubble); + } + return wrap; + } + + function beginEditPin(idx) { + const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]'); + if (!wrapEl) return; + // Strip any existing bubble (but keep the dot) + wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove()); + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Note…'; + Object.assign(input.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + border: '1px solid ' + C.brand, + outline: 'none', marginTop: '-2px', + width: '220px', pointerEvents: 'auto', + }); + const originalText = annotState.comments[idx].text || ''; + input.value = originalText; + wrapEl.appendChild(input); + annotEditing = { idx, input, wrapEl, originalText }; + input.addEventListener('keydown', onAnnotInputKey, true); + input.addEventListener('blur', () => { + // Fires on both focus-loss and programmatic blur; commit unless we + // already handled it. + if (annotEditing && annotEditing.input === input) finalizeEditingPin(); + }); + // Stop clicks/pointerdowns inside the input from bubbling to the overlay + ['pointerdown', 'click'].forEach(ev => { + input.addEventListener(ev, e => e.stopPropagation()); + }); + setTimeout(() => input.focus(), 0); + } + + function onAnnotInputKey(e) { + if (e.key === 'Enter') { + e.preventDefault(); e.stopPropagation(); + finalizeEditingPin(); + } else if (e.key === 'Escape') { + e.preventDefault(); e.stopPropagation(); + cancelEditingPin(); + } else { + // Keep arrows / backspace from hitting global handlers + e.stopPropagation(); + } + } + + function finalizeEditingPin() { + if (!annotEditing) return; + const { idx, input } = annotEditing; + const text = input.value.trim(); + annotEditing = null; + if (text) annotState.comments[idx].text = text; + else annotState.comments.splice(idx, 1); + renderAllPins(); + } + + function cancelEditingPin() { + if (!annotEditing) return; + const { idx, originalText } = annotEditing; + annotEditing = null; + // If the pin had text before this edit, revert to it. If it was a + // just-created empty pin, Escape removes it. + if (originalText) { + annotState.comments[idx].text = originalText; + } else { + annotState.comments.splice(idx, 1); + } + renderAllPins(); + } + + // Build a detached annotation subtree suitable for injection into the clone + // modern-screenshot creates. Coordinates are element-local so this slots + // straight into an element that's been made position:relative. Takes an + // explicit snapshot so it works after annotState has been cleared. + function buildAnnotationsForCapture(rect, snapshot) { + const comments = snapshot ? snapshot.comments : annotState.comments; + const strokes = snapshot ? snapshot.strokes : annotState.strokes; + if (comments.length === 0 && strokes.length === 0) return null; + const wrap = document.createElement('div'); + Object.assign(wrap.style, { + position: 'absolute', top: '0', left: '0', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', overflow: 'visible', + }); + if (strokes.length > 0) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height); + Object.assign(svg.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', overflow: 'visible', + }); + for (const s of strokes) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('stroke', C.brand); + path.setAttribute('stroke-width', '3'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('fill', 'none'); + path.setAttribute('d', pointsToPath(s.points)); + svg.appendChild(path); + } + wrap.appendChild(svg); + } + for (const c of comments) { + // idx=-1 means non-interactive; pointerEvents stay off in the clone + wrap.appendChild(buildPinElement(c, -1)); + } + return wrap; } // --------------------------------------------------------------------------- @@ -416,8 +901,10 @@ fontSize: '11px', color: C.ash, whiteSpace: 'nowrap', marginLeft: 'auto', }); + // Variants currently arrive atomically in a single file edit, so a + // per-variant counter would lie. Say what's true. status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + (arrivedVariants + 1) + ' of ' + expectedVariants + '...' + ? 'Generating ' + expectedVariants + ' variants...' : 'Done'; row.appendChild(status); @@ -838,6 +1325,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + hideShaderOverlay(); updateBarContent('cycling'); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -860,6 +1348,10 @@ positionBar(); showHighlight(selectedElement); } + if (annotActive) positionAnnotOverlay(selectedElement); + // Shader overlay (via debug P toggle or generation) is repositioned + // by its own branch below; debug no longer has a separate overlay. + if (shaderState) positionShaderOverlay(); scrollRaf = requestAnimationFrame(tick); } scrollRaf = requestAnimationFrame(tick); @@ -937,6 +1429,8 @@ } hideBar(); hideHighlight(); + hideShaderOverlay(); + hideAnnotOverlay(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } clearSession(); @@ -976,6 +1470,8 @@ if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { hideBar(); stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -989,15 +1485,19 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); } function handleKeyDown(e) { + // When the annotation input is focused, let it handle its own keys. + if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); state = 'PICKING'; return; } + if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { hideHighlight(); state = 'IDLE'; return; } @@ -1024,6 +1524,8 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); return; @@ -1035,6 +1537,8 @@ } else { // CONFIGURING: re-select the new element and refresh the bar selectedElement = next; + clearAnnotations(); + showAnnotOverlay(next); showBar('configure'); startScrollTracking(); } @@ -1056,25 +1560,408 @@ const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; + // Commit any pending pin edit BEFORE we snapshot annotations. + if (annotEditing) finalizeEditingPin(); + currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; - sendEvent({ + // Flip to GENERATING immediately so the bar morphs without waiting on + // capture + upload. The event is emitted from captureAndEmit() once the + // screenshot is uploaded (or capture fails — we still emit, just without + // screenshotPath). + const elForCapture = selectedElement; + const captureRect = elForCapture.getBoundingClientRect(); + const snapshot = { + comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), + strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })), + }; + const basePayload = { type: 'generate', id: currentSessionId, action: selectedAction, freeformPrompt: prompt || undefined, count: selectedCount, pageUrl: location.pathname, - element: extractContext(selectedElement), - }); + element: extractContext(elForCapture), + }; + if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments; + if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes; + + // Hide the interactive overlay so it doesn't linger during generation. + hideAnnotOverlay(); + clearAnnotations(); state = 'GENERATING'; showBar('generating'); saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + + captureAndEmit(elForCapture, basePayload, snapshot, captureRect); + } + + // --------------------------------------------------------------------------- + // Screenshot capture + upload + // --------------------------------------------------------------------------- + + let msLoadPromise = null; + function loadModernScreenshot() { + if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot); + if (msLoadPromise) return msLoadPromise; + msLoadPromise = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; + s.onload = () => resolve(window.modernScreenshot); + s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; + document.head.appendChild(s); + }); + return msLoadPromise; + } + + // Collect @font-face rules from every stylesheet on the page. Cross-origin + // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules + // access, so modern-screenshot can't embed them on its own — the resulting + // SVG falls back to system fonts and text re-wraps + renders with different + // weight. We fetch the raw CSS text (CORS-permitted for these providers), + // extract @font-face blocks, inline the referenced font files as base64 + // data URIs (SVGs rasterized via canvas can't fetch external resources, + // so URLs inside the SVG silently fail without this), and pass the result + // to modern-screenshot as font.cssText. + const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i; + const FONT_MIME = { + woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject', + }; + function bufferToBase64(buf) { + const bytes = new Uint8Array(buf); + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); + } + async function inlineFontUrls(cssText) { + const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g; + const urls = new Set(); + let m; + while ((m = urlRe.exec(cssText))) { + if (FONT_EXT_RE.test(m[2])) urls.add(m[2]); + } + const map = new Map(); + await Promise.all([...urls].map(async (url) => { + try { + const res = await fetch(url); + if (!res.ok) return; + const buf = await res.arrayBuffer(); + const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2'; + const mime = FONT_MIME[ext] || 'application/octet-stream'; + map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf)); + } catch { /* skip; fall through to URL */ } + })); + return cssText.replace(urlRe, (orig, q, url) => { + const data = map.get(url); + return data ? 'url(' + q + data + q + ')' : orig; + }); + } + async function collectFontCssText() { + const chunks = []; + const fontFaceRe = /@font-face\s*\{[^}]*\}/g; + for (const sheet of document.styleSheets) { + try { + const rules = sheet.cssRules; + for (const rule of rules) { + if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) { + chunks.push(rule.cssText); + } + } + } catch { + if (!sheet.href) continue; + try { + const res = await fetch(sheet.href); + if (!res.ok) continue; + const text = await res.text(); + let m2; + while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]); + } catch { /* ignore; capture is best-effort */ } + } + } + if (chunks.length === 0) return ''; + return inlineFontUrls(chunks.join('\n')); + } + + // Capture the element (with current annotations baked in) and return a PNG + // Blob. Shared between the Go flow (uploads it to the server) and the + // debug toggle (displays it as an overlay for side-by-side comparison). + async function captureElementToBlob(el, snapshot, rect) { + try { if (document.fonts?.ready) await document.fonts.ready; } catch {} + const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); + let annotNode = null; + let savedPosition = null; + if (hasAnnotations) { + const pos = getComputedStyle(el).position; + if (pos === 'static') { + savedPosition = el.style.position; + el.style.position = 'relative'; + } + annotNode = buildAnnotationsForCapture(rect, snapshot); + el.appendChild(annotNode); + } + try { + const ms = await loadModernScreenshot(); + const fontCssText = await collectFontCssText(); + return await ms.domToBlob(el, { + scale: Math.min(window.devicePixelRatio || 1, 2), + backgroundColor: getComputedStyle(document.body).backgroundColor || '#ffffff', + font: fontCssText ? { cssText: fontCssText } : undefined, + }); + } finally { + if (annotNode) annotNode.remove(); + if (savedPosition !== null) el.style.position = savedPosition; + } + } + + async function captureAndEmit(el, basePayload, snapshot, rect) { + let screenshotPath; + let blob; + try { + blob = await captureElementToBlob(el, snapshot, rect); + } catch (err) { + console.warn('[impeccable] capture failed, proceeding without screenshot:', err); + } + // Light up the shader overlay the moment capture is ready — no reason to + // wait for the upload to complete before the user sees something alive. + if (blob && state === 'GENERATING') { + showShaderOverlay(el, blob, rect); + } + if (blob) { + try { + const uploadRes = await fetch( + 'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) + + '&eventId=' + encodeURIComponent(basePayload.id), + { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob }, + ); + if (uploadRes.ok) { + const { path: p } = await uploadRes.json(); + screenshotPath = p; + } else { + console.warn('[impeccable] annotation upload failed:', uploadRes.status); + } + } catch (err) { + console.warn('[impeccable] annotation upload failed:', err); + } + } + sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); + } + + // --------------------------------------------------------------------------- + // Shader overlay — renders the captured screenshot as a WebGL texture and + // runs an editorial "ink-wash" fragment shader over it during generation. + // A single rolling band sweeps top-to-bottom, desaturating + tinting magenta + // and leaving a soft trail. Makes the wait feel like a letterpress scan + // instead of a dead spinner. + // --------------------------------------------------------------------------- + + const SHADER_VS = `attribute vec2 a_position; +attribute vec2 a_uv; +varying vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_position, 0.0, 1.0); +}`; + + const SHADER_FS = `precision highp float; +uniform sampler2D u_texture; +uniform float u_time; +uniform vec2 u_resolution; +uniform vec3 u_accent; +varying vec2 v_uv; + +// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at +// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean +// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below" +// failure that reversed-edge smoothstep would give). +float bandAt(float d, float leadW, float trailW) { + float above = smoothstep(-leadW, 0.0, d); + float below = 1.0 - smoothstep(0.0, trailW, d); + return above * below; +} + +void main() { + vec2 uv = v_uv; + // Roller sweeps top-to-bottom with small overshoot so each cycle enters + // and exits the element cleanly. + float phase = fract(u_time / 3.4); + float y = phase * 1.25 - 0.12; + float band = bandAt(uv.y - y, 0.05, 0.32); + + // Halftone cell grid (fixed ~10 px pitch). + float cellPx = 10.0; + vec2 gridUv = uv * u_resolution / cellPx; + vec2 cellId = floor(gridUv); + vec2 cellUv = fract(gridUv) - 0.5; + vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution; + vec3 cellImg = texture2D(u_texture, sampleCenter).rgb; + float luma = dot(cellImg, vec3(0.299, 0.587, 0.114)); + // Darker cells → bigger magenta dots (classic risograph halftone curve). + float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56; + float dotMask = smoothstep(radius + 0.06, radius, length(cellUv)); + vec3 paper = vec3(0.975, 0.965, 0.955); + vec3 dotLayer = mix(paper, u_accent, dotMask); + + // Blend the halftone layer in where the roller is passing; leave the + // element pristine elsewhere. + vec3 base = texture2D(u_texture, uv).rgb; + gl_FragColor = vec4(mix(base, dotLayer, band), 1.0); +}`; + + // Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350)) + const SHADER_ACCENT = [0.82, 0.16, 0.47]; + let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + + function compileShader(gl, type, source) { + const sh = gl.createShader(type); + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(sh); + gl.deleteShader(sh); + throw new Error('shader compile failed: ' + info); + } + return sh; + } + + function positionShaderOverlay() { + if (!shaderState || !selectedElement) return; + const r = selectedElement.getBoundingClientRect(); + Object.assign(shaderState.canvas.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + } + + function hideShaderOverlay() { + if (!shaderState) return; + if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); + if (shaderState.canvas) shaderState.canvas.remove(); + const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + shaderState = null; + } + + async function showShaderOverlay(el, blob, rect) { + hideShaderOverlay(); + if (!blob || !el) return; + const canvas = document.createElement('canvas'); + canvas.id = PREFIX + '-shader'; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + Object.assign(canvas.style, { + position: 'fixed', + top: rect.top + 'px', left: rect.left + 'px', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', + zIndex: Z.bar - 1, + }); + document.body.appendChild(canvas); + + const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) + || canvas.getContext('experimental-webgl'); + if (!gl) { + // WebGL unavailable — fall back to a plain overlay so the user + // still sees something meaningful during generation. + canvas.remove(); + const img = document.createElement('img'); + img.src = URL.createObjectURL(blob); + img.id = PREFIX + '-shader'; + Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' }); + document.body.appendChild(img); + shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 }; + return; + } + + let program, texture; + try { + const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS); + program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error('program link failed: ' + gl.getProgramInfoLog(program)); + } + // Full-screen quad + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + -1, -1, 0, 1, + 1, -1, 1, 1, + -1, 1, 0, 0, + -1, 1, 0, 0, + 1, -1, 1, 1, + 1, 1, 1, 0, + ]), gl.STATIC_DRAW); + const posLoc = gl.getAttribLocation(program, 'a_position'); + const uvLoc = gl.getAttribLocation(program, 'a_uv'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(uvLoc); + gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8); + } catch (err) { + console.warn('[impeccable] shader setup failed:', err); + canvas.remove(); + return; + } + + // Upload the screenshot as a texture + let bitmap; + try { + bitmap = await createImageBitmap(blob); + } catch { + // Safari fallback: go via a regular Image + const imgUrl = URL.createObjectURL(blob); + const img = new Image(); + img.src = imgUrl; + await new Promise((r, rej) => { img.onload = r; img.onerror = rej; }); + bitmap = img; + URL.revokeObjectURL(imgUrl); + } + texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + if (bitmap.close) bitmap.close(); + + const uTime = gl.getUniformLocation(program, 'u_time'); + const uRes = gl.getUniformLocation(program, 'u_resolution'); + const uAccent = gl.getUniformLocation(program, 'u_accent'); + const uTex = gl.getUniformLocation(program, 'u_texture'); + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; + function frame() { + if (!shaderState) return; + const elapsed = (performance.now() - shaderState.startTime) / 1000; + const t = shaderState.reduced ? 0.0 : elapsed; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.useProgram(program); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.uniform1i(uTex, 0); + gl.uniform1f(uTime, t); + gl.uniform2f(uRes, canvas.width, canvas.height); + gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]); + gl.drawArrays(gl.TRIANGLES, 0, 6); + shaderState.rafId = requestAnimationFrame(frame); + } + frame(); } function handleAccept() { @@ -2713,6 +3600,7 @@ function init() { initHighlight(); + initAnnotOverlay(); initBar(); initActionPicker(); initGlobalBar(); diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs index 109aa768e..97163b255 100644 --- a/.opencode/skills/impeccable/scripts/live-server.mjs +++ b/.opencode/skills/impeccable/scripts/live-server.mjs @@ -18,7 +18,6 @@ import { randomUUID } from 'node:crypto'; import { spawn, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; @@ -56,8 +55,13 @@ const state = { pendingEvents: [], // browser events waiting for agent poll pendingPolls: [], // agent poll callbacks waiting for browser events exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots }; +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + function enqueueEvent(event) { if (state.pendingPolls.length > 0) { state.pendingPolls.shift()(event); @@ -134,6 +138,10 @@ function validateEvent(msg) { if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; return null; case 'accept': if (!msg.id) return 'accept: missing id'; @@ -175,6 +183,83 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { return; } + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + // --- Health --- if (p === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -250,10 +335,11 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } const absPath = path.resolve(process.cwd(), filePath); if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } - try { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(fs.readFileSync(absPath, 'utf-8')); - } catch { res.writeHead(404); res.end('File not found'); } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); return; } @@ -411,6 +497,9 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } for (const res of state.sseClients) { try { res.end(); } catch {} } state.sseClients.clear(); for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); @@ -442,12 +531,14 @@ Options: --help Show this help Endpoints: - /live.js Browser script (element picker + variant cycling) - /detect.js Detection overlay (backwards compatible) - /events SSE stream (server→browser) + POST (browser→server) - /poll Long-poll for agent CLI - /source Raw source file reader (no-HMR fallback) - /health Health check`); + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); process.exit(0); } @@ -531,6 +622,12 @@ try { state.token = randomUUID(); const portArg = args.find(a => a.startsWith('--port=')); state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); const { detectScript, liveScript } = loadBrowserScripts(); const liveScriptWithToken = diff --git a/.opencode/skills/impeccable/scripts/modern-screenshot.umd.js b/.opencode/skills/impeccable/scripts/modern-screenshot.umd.js new file mode 100644 index 000000000..a9c5208f6 --- /dev/null +++ b/.opencode/skills/impeccable/scripts/modern-screenshot.umd.js @@ -0,0 +1,14 @@ +(function(y,v){typeof exports=="object"&&typeof module!="undefined"?v(exports):typeof define=="function"&&define.amd?define(["exports"],v):(y=typeof globalThis!="undefined"?globalThis:y||self,v(y.modernScreenshot={}))})(this,function(y){"use strict";var rr=Object.defineProperty,nr=Object.defineProperties;var or=Object.getOwnPropertyDescriptors;var Z=Object.getOwnPropertySymbols;var xe=Object.prototype.hasOwnProperty,Me=Object.prototype.propertyIsEnumerable;var Oe=Math.pow,Le=(y,v,N)=>v in y?rr(y,v,{enumerable:!0,configurable:!0,writable:!0,value:N}):y[v]=N,D=(y,v)=>{for(var N in v||(v={}))xe.call(v,N)&&Le(y,N,v[N]);if(Z)for(var N of Z(v))Me.call(v,N)&&Le(y,N,v[N]);return y},M=(y,v)=>nr(y,or(v));var je=(y,v)=>{var N={};for(var R in y)xe.call(y,R)&&v.indexOf(R)<0&&(N[R]=y[R]);if(y!=null&&Z)for(var R of Z(y))v.indexOf(R)<0&&Me.call(y,R)&&(N[R]=y[R]);return N};var C=(y,v,N)=>new Promise((R,O)=>{var X=P=>{try{q(N.next(P))}catch(W){O(W)}},j=P=>{try{q(N.throw(P))}catch(W){O(W)}},q=P=>P.done?R(P.value):Promise.resolve(P.value).then(X,j);q((N=N.apply(y,v)).next())});var Be;function v(e,t){return e[13]=1,e[14]=t>>8,e[15]=t&255,e[16]=t>>8,e[17]=t&255,e}const N=112,R=72,O=89,X=115;let j;function q(){const e=new Int32Array(256);for(let t=0;t<256;t++){let r=t;for(let n=0;n<8;n++)r=r&1?3988292384^r>>>1:r>>>1;e[t]=r}return e}function P(e){let t=-1;j||(j=q());for(let r=0;r>>8;return t^-1}function W(e){const t=e.length-1;for(let r=t;r>=4;r--)if(e[r-4]===9&&e[r-3]===N&&e[r-2]===R&&e[r-1]===O&&e[r]===X)return r-3;return 0}function ae(e,t,r=!1){const n=new Uint8Array(13);t*=39.3701,n[0]=N,n[1]=R,n[2]=O,n[3]=X,n[4]=t>>>24,n[5]=t>>>16,n[6]=t>>>8,n[7]=t&255,n[8]=n[4],n[9]=n[5],n[10]=n[6],n[11]=n[7],n[12]=1;const i=P(n),a=new Uint8Array(4);if(a[0]=i>>>24,a[1]=i>>>16,a[2]=i>>>8,a[3]=i&255,r){const s=W(e);return e.set(n,s),e.set(a,s+13),e}else{const s=new Uint8Array(4);s[0]=0,s[1]=0,s[2]=0,s[3]=9;const o=new Uint8Array(54);return o.set(e,0),o.set(s,33),o.set(n,37),o.set(a,50),o}}const qe="AAlwSFlz",We="AAAJcEhZ",He="AAAACXBI";function Ve(e){let t=e.indexOf(qe);return t===-1&&(t=e.indexOf(We)),t===-1&&(t=e.indexOf(He)),t}const se="[modern-screenshot]",U=typeof window!="undefined",ze=U&&"Worker"in window,ie=U&&"atob"in window,Xe=U&&"btoa"in window,ee=U?(Be=window.navigator)==null?void 0:Be.userAgent:"",le=ee.includes("Chrome"),G=ee.includes("AppleWebKit")&&!le,te=ee.includes("Firefox"),Ge=e=>e&&"__CONTEXT__"in e,Ye=e=>e.constructor.name==="CSSFontFaceRule",Je=e=>e.constructor.name==="CSSImportRule",Ke=e=>e.constructor.name==="CSSLayerBlockRule",I=e=>e.nodeType===1,H=e=>typeof e.className=="object",ce=e=>e.tagName==="image",Qe=e=>e.tagName==="use",V=e=>I(e)&&typeof e.style!="undefined"&&!H(e),Ze=e=>e.nodeType===8,et=e=>e.nodeType===3,$=e=>e.tagName==="IMG",Y=e=>e.tagName==="VIDEO",tt=e=>e.tagName==="CANVAS",rt=e=>e.tagName==="TEXTAREA",nt=e=>e.tagName==="INPUT",ot=e=>e.tagName==="STYLE",at=e=>e.tagName==="SCRIPT",st=e=>e.tagName==="SELECT",it=e=>e.tagName==="SLOT",lt=e=>e.tagName==="IFRAME",ct=(...e)=>console.warn(se,...e);function ut(e){var r;const t=(r=e==null?void 0:e.createElement)==null?void 0:r.call(e,"canvas");return t&&(t.height=t.width=1),!!t&&"toDataURL"in t&&!!t.toDataURL("image/webp").includes("image/webp")}const re=e=>e.startsWith("data:");function ue(e,t){if(e.match(/^[a-z]+:\/\//i))return e;if(U&&e.match(/^\/\//))return window.location.protocol+e;if(e.match(/^[a-z]+:/i)||!U)return e;const r=J().implementation.createHTMLDocument(),n=r.createElement("base"),i=r.createElement("a");return r.head.appendChild(n),r.body.appendChild(i),t&&(n.href=t),i.href=e,i.href}function J(e){var t;return(t=e&&I(e)?e==null?void 0:e.ownerDocument:e)!=null?t:window.document}const K="http://www.w3.org/2000/svg";function fe(e,t,r){const n=J(r).createElementNS(K,"svg");return n.setAttributeNS(null,"width",e.toString()),n.setAttributeNS(null,"height",t.toString()),n.setAttributeNS(null,"viewBox",`0 0 ${e} ${t}`),n}function de(e,t){let r=new XMLSerializer().serializeToString(e);return t&&(r=r.replace(/[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu,"")),`data:image/svg+xml;charset=utf-8,${encodeURIComponent(r)}`}function ft(e,t="image/png",r=1){return C(this,null,function*(){try{return yield new Promise((n,i)=>{e.toBlob(a=>{a?n(a):i(new Error("Blob is null"))},t,r)})}catch(n){if(ie)return dt(e.toDataURL(t,r));throw n}})}function dt(e){var o,c;const[t,r]=e.split(","),n=(c=(o=t.match(/data:(.+);/))==null?void 0:o[1])!=null?c:void 0,i=window.atob(r),a=i.length,s=new Uint8Array(a);for(let u=0;u{const i=new FileReader;i.onload=()=>r(i.result),i.onerror=()=>n(i.error),i.onabort=()=>n(new Error(`Failed read blob to ${t}`)),t==="dataUrl"?i.readAsDataURL(e):t==="arrayBuffer"&&i.readAsArrayBuffer(e)})}const gt=e=>ge(e,"dataUrl"),mt=e=>ge(e,"arrayBuffer");function _(e,t){const r=J(t).createElement("img");return r.decoding="sync",r.loading="eager",r.src=e,r}function L(e,t){return new Promise(r=>{const{timeout:n,ownerDocument:i,onError:a,onWarn:s}=t!=null?t:{},o=typeof e=="string"?_(e,J(i)):e;let c=null,u=null;function l(){r(o),c&&clearTimeout(c),u==null||u()}if(n&&(c=setTimeout(l,n)),Y(o)){const d=o.currentSrc||o.src;if(!d)return o.poster?L(o.poster,t).then(r):l();if(o.readyState>=2)return l();const m=l,f=h=>{s==null||s("Failed video load",d,h),a==null||a(h),l()};u=()=>{o.removeEventListener("loadeddata",m),o.removeEventListener("error",f)},o.addEventListener("loadeddata",m,{once:!0}),o.addEventListener("error",f,{once:!0})}else{const d=ce(o)?o.href.baseVal:o.currentSrc||o.src;if(!d)return l();const m=()=>C(this,null,function*(){if($(o)&&"decode"in o)try{yield o.decode()}catch(h){s==null||s("Failed to decode image, trying to render anyway",o.dataset.originalSrc||d,h)}l()}),f=h=>{s==null||s("Failed image load",o.dataset.originalSrc||d,h),l()};if($(o)&&o.complete)return m();u=()=>{o.removeEventListener("load",m),o.removeEventListener("error",f)},o.addEventListener("load",m,{once:!0}),o.addEventListener("error",f,{once:!0})}})}function me(e,t){return C(this,null,function*(){V(e)&&($(e)||Y(e)?yield L(e,t):yield Promise.all(["img","video"].flatMap(r=>Array.from(e.querySelectorAll(r)).map(n=>L(n,t)))))})}const he=function(){let t=0;const r=()=>`0000${(Math.random()*Oe(36,4)<<0).toString(36)}`.slice(-4);return()=>(t+=1,`u${r()}${t}`)}();function we(e){return e==null?void 0:e.split(",").map(t=>t.trim().replace(/"|'/g,"").toLowerCase()).filter(Boolean)}let pe=0;function ht(e){const t=`${se}[#${pe}]`;return pe++,{time:r=>e&&console.time(`${t} ${r}`),timeEnd:r=>e&&console.timeEnd(`${t} ${r}`),warn:(...r)=>e&&ct(...r)}}function wt(e){return{cache:e?"no-cache":"force-cache"}}function k(e,t){return C(this,null,function*(){return Ge(e)?e:ye(e,M(D({},t),{autoDestruct:!0}))})}function ye(e,t){return C(this,null,function*(){var f,h,g,p,E;const{scale:r=1,workerUrl:n,workerNumber:i=1}=t||{},a=!!(t!=null&&t.debug),s=(f=t==null?void 0:t.features)!=null?f:!0,o=(h=e.ownerDocument)!=null?h:U?window.document:void 0,c=(p=(g=e.ownerDocument)==null?void 0:g.defaultView)!=null?p:U?window:void 0,u=new Map,l=M(D({width:0,height:0,quality:1,type:"image/png",scale:r,backgroundColor:null,style:null,filter:null,maximumCanvasSize:0,timeout:3e4,progress:null,debug:a,fetch:D({requestInit:wt((E=t==null?void 0:t.fetch)==null?void 0:E.bypassingCache),placeholderImage:"data:image/png;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",bypassingCache:!1},t==null?void 0:t.fetch),fetchFn:null,font:{},drawImageInterval:100,workerUrl:null,workerNumber:i,onCloneEachNode:null,onCloneNode:null,onEmbedNode:null,onCreateForeignObjectSvg:null,includeStyleProperties:null,autoDestruct:!1},t),{__CONTEXT__:!0,log:ht(a),node:e,ownerDocument:o,ownerWindow:c,dpi:r===1?null:96*r,svgStyleElement:be(o),svgDefsElement:o==null?void 0:o.createElementNS(K,"defs"),svgStyles:new Map,defaultComputedStyles:new Map,workers:[...Array.from({length:ze&&n&&i?i:0})].map(()=>{try{const b=new Worker(n);return b.onmessage=w=>C(this,null,function*(){var A,F,B,$e;const{url:S,result:T}=w.data;T?(F=(A=u.get(S))==null?void 0:A.resolve)==null||F.call(A,T):($e=(B=u.get(S))==null?void 0:B.reject)==null||$e.call(B,new Error(`Error receiving message from worker: ${S}`))}),b.onmessageerror=w=>{var T,A;const{url:S}=w.data;(A=(T=u.get(S))==null?void 0:T.reject)==null||A.call(T,new Error(`Error receiving message from worker: ${S}`))},b}catch(b){return l.log.warn("Failed to new Worker",b),null}}).filter(Boolean),fontFamilies:new Map,fontCssTexts:new Map,acceptOfImage:`${[ut(o)&&"image/webp","image/svg+xml","image/*","*/*"].filter(Boolean).join(",")};q=0.8`,requests:u,drawImageCount:0,tasks:[],features:s,isEnable:b=>{var w,S;return b==="restoreScrollPosition"?typeof s=="boolean"?!1:(w=s[b])!=null?w:!1:typeof s=="boolean"?s:(S=s[b])!=null?S:!0},shadowRoots:[]});l.log.time("wait until load"),yield me(e,{timeout:l.timeout,onWarn:l.log.warn}),l.log.timeEnd("wait until load");const{width:d,height:m}=pt(e,l);return l.width=d,l.height=m,l})}function be(e){if(!e)return;const t=e.createElement("style"),r=t.ownerDocument.createTextNode(` +.______background-clip--text { + background-clip: text; + -webkit-background-clip: text; +} +`);return t.appendChild(r),t}function pt(e,t){let{width:r,height:n}=t;if(I(e)&&(!r||!n)){const i=e.getBoundingClientRect();r=r||i.width||Number(e.getAttribute("width"))||0,n=n||i.height||Number(e.getAttribute("height"))||0}return{width:r,height:n}}function yt(e,t){return C(this,null,function*(){const{log:r,timeout:n,drawImageCount:i,drawImageInterval:a}=t;r.time("image to canvas");const s=yield L(e,{timeout:n,onWarn:t.log.warn}),{canvas:o,context2d:c}=bt(e.ownerDocument,t),u=()=>{try{c==null||c.drawImage(s,0,0,o.width,o.height)}catch(l){t.log.warn("Failed to drawImage",l)}};if(u(),t.isEnable("fixSvgXmlDecode"))for(let l=0;l{setTimeout(()=>{c==null||c.clearRect(0,0,o.width,o.height),u(),d()},l+a)});return t.drawImageCount=0,r.timeEnd("image to canvas"),o})}function bt(e,t){const{width:r,height:n,scale:i,backgroundColor:a,maximumCanvasSize:s}=t,o=e.createElement("canvas");o.width=Math.floor(r*i),o.height=Math.floor(n*i),o.style.width=`${r}px`,o.style.height=`${n}px`,s&&(o.width>s||o.height>s)&&(o.width>s&&o.height>s?o.width>o.height?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s):o.width>s?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s));const c=o.getContext("2d");return c&&a&&(c.fillStyle=a,c.fillRect(0,0,o.width,o.height)),{canvas:o,context2d:c}}function Se(e,t){if(e.ownerDocument)try{const a=e.toDataURL();if(a!=="data:,")return _(a,e.ownerDocument)}catch(a){t.log.warn("Failed to clone canvas",a)}const r=e.cloneNode(!1),n=e.getContext("2d"),i=r.getContext("2d");try{return n&&i&&i.putImageData(n.getImageData(0,0,e.width,e.height),0,0),r}catch(a){t.log.warn("Failed to clone canvas",a)}return r}function St(e,t){var r;try{if((r=e==null?void 0:e.contentDocument)!=null&&r.documentElement)return ne(e.contentDocument.documentElement,t)}catch(n){t.log.warn("Failed to clone iframe",n)}return e.cloneNode(!1)}function Et(e){const t=e.cloneNode(!1);return e.currentSrc&&e.currentSrc!==e.src&&(t.src=e.currentSrc,t.srcset=""),t.loading==="lazy"&&(t.loading="eager"),t}function Ct(e,t){return C(this,null,function*(){if(e.ownerDocument&&!e.currentSrc&&e.poster)return _(e.poster,e.ownerDocument);const r=e.cloneNode(!1);r.crossOrigin="anonymous",e.currentSrc&&e.currentSrc!==e.src&&(r.src=e.currentSrc);const n=r.ownerDocument;if(n){let i=!0;if(yield L(r,{onError:()=>i=!1,onWarn:t.log.warn}),!i)return e.poster?_(e.poster,e.ownerDocument):r;r.currentTime=e.currentTime,yield new Promise(s=>{r.addEventListener("seeked",s,{once:!0})});const a=n.createElement("canvas");a.width=e.offsetWidth,a.height=e.offsetHeight;try{const s=a.getContext("2d");s&&s.drawImage(r,0,0,a.width,a.height)}catch(s){return t.log.warn("Failed to clone video",s),e.poster?_(e.poster,e.ownerDocument):r}return Se(a,t)}return r})}function Tt(e,t){return tt(e)?Se(e,t):lt(e)?St(e,t):$(e)?Et(e):Y(e)?Ct(e,t):e.cloneNode(!1)}function vt(e){let t=e.sandbox;if(!t){const{ownerDocument:r}=e;try{r&&(t=r.createElement("iframe"),t.id=`__SANDBOX__${he()}`,t.width="0",t.height="0",t.style.visibility="hidden",t.style.position="fixed",r.body.appendChild(t),t.srcdoc='',e.sandbox=t)}catch(n){e.log.warn("Failed to getSandBox",n)}}return t}const At=["width","height","-webkit-text-fill-color"],Nt=["stroke","fill"];function Ee(e,t,r){const{defaultComputedStyles:n}=r,i=e.nodeName.toLowerCase(),a=H(e)&&i!=="svg",s=a?Nt.map(g=>[g,e.getAttribute(g)]).filter(([,g])=>g!==null):[],o=[a&&"svg",i,s.map((g,p)=>`${g}=${p}`).join(","),t].filter(Boolean).join(":");if(n.has(o))return n.get(o);const c=vt(r),u=c==null?void 0:c.contentWindow;if(!u)return new Map;const l=u==null?void 0:u.document;let d,m;a?(d=l.createElementNS(K,"svg"),m=d.ownerDocument.createElementNS(d.namespaceURI,i),s.forEach(([g,p])=>{m.setAttributeNS(null,g,p)}),d.appendChild(m)):d=m=l.createElement(i),m.textContent=" ",l.body.appendChild(d);const f=u.getComputedStyle(m,t),h=new Map;for(let g=f.length,p=0;pn.set(d,l));function s(c){const u=e.getPropertyValue(c),l=e.getPropertyPriority(c),d=c.lastIndexOf("-"),m=d>-1?c.substring(0,d):void 0;if(m){let f=a.get(m);f||(f=new Map,a.set(m,f)),f.set(c,[u,l])}t.get(c)===u&&!l||(m?i.push(m):n.set(c,[u,l]))}return n}function Rt(e,t,r,n){var d,m,f,h;const{ownerWindow:i,includeStyleProperties:a,currentParentNodeStyle:s}=n,o=t.style,c=i.getComputedStyle(e),u=Ee(e,null,n);s==null||s.forEach((g,p)=>{u.delete(p)});const l=Ce(c,u,a);l.delete("transition-property"),l.delete("all"),l.delete("d"),l.delete("content"),r&&(l.delete("position"),l.delete("margin-top"),l.delete("margin-right"),l.delete("margin-bottom"),l.delete("margin-left"),l.delete("margin-block-start"),l.delete("margin-block-end"),l.delete("margin-inline-start"),l.delete("margin-inline-end"),l.set("box-sizing",["border-box",""])),((d=l.get("background-clip"))==null?void 0:d[0])==="text"&&t.classList.add("______background-clip--text"),le&&(l.has("font-kerning")||l.set("font-kerning",["normal",""]),(((m=l.get("overflow-x"))==null?void 0:m[0])==="hidden"||((f=l.get("overflow-y"))==null?void 0:f[0])==="hidden")&&((h=l.get("text-overflow"))==null?void 0:h[0])==="ellipsis"&&e.scrollWidth===e.clientWidth&&l.set("text-overflow",["clip",""]));for(let g=o.length,p=0;p{o.setProperty(E,g,p)}),l}function It(e,t){(rt(e)||nt(e)||st(e))&&t.setAttribute("value",e.value)}const kt=["::before","::after"],Dt=["::-webkit-scrollbar","::-webkit-scrollbar-button","::-webkit-scrollbar-thumb","::-webkit-scrollbar-track","::-webkit-scrollbar-track-piece","::-webkit-scrollbar-corner","::-webkit-resizer"];function Pt(e,t,r,n,i){const{ownerWindow:a,svgStyleElement:s,svgStyles:o,currentNodeStyle:c}=n;if(!s||!a)return;function u(l){var w;const d=a.getComputedStyle(e,l);let m=d.getPropertyValue("content");if(!m||m==="none")return;i==null||i(m),m=m.replace(/(')|(")|(counter\(.+\))/g,"");const f=[he()],h=Ee(e,l,n);c==null||c.forEach((S,T)=>{h.delete(T)});const g=Ce(d,h,n.includeStyleProperties);g.delete("content"),g.delete("-webkit-locale"),((w=g.get("background-clip"))==null?void 0:w[0])==="text"&&t.classList.add("______background-clip--text");const p=[`content: '${m}';`];if(g.forEach(([S,T],A)=>{p.push(`${A}: ${S}${T?" !important":""};`)}),p.length===1)return;try{t.className=[t.className,...f].join(" ")}catch(S){n.log.warn("Failed to copyPseudoClass",S);return}const E=p.join(` + `);let b=o.get(E);b||(b=[],o.set(E,b)),b.push(`.${f[0]}${l}`)}kt.forEach(u),r&&Dt.forEach(u)}const Te=new Set(["symbol"]);function ve(e,t,r,n,i){return C(this,null,function*(){if(I(r)&&(ot(r)||at(r))||n.filter&&!n.filter(r))return;Te.has(t.nodeName)||Te.has(r.nodeName)?n.currentParentNodeStyle=void 0:n.currentParentNodeStyle=n.currentNodeStyle;const a=yield ne(r,n,!1,i);n.isEnable("restoreScrollPosition")&&Ut(e,a),t.appendChild(a)})}function Ae(e,t,r,n){return C(this,null,function*(){var a;let i=e.firstChild;I(e)&&e.shadowRoot&&(i=(a=e.shadowRoot)==null?void 0:a.firstChild,r.shadowRoots.push(e.shadowRoot));for(let s=i;s;s=s.nextSibling)if(!Ze(s))if(I(s)&&it(s)&&typeof s.assignedNodes=="function"){const o=s.assignedNodes();for(let c=0;ce.clientHeight||e.scrollWidth>e.clientWidth)}const p=(d=h.get("text-transform"))==null?void 0:d[0],E=we((m=h.get("font-family"))==null?void 0:m[0]),b=E?w=>{p==="uppercase"?w=w.toUpperCase():p==="lowercase"?w=w.toLowerCase():p==="capitalize"&&(w=w[0].toUpperCase()+w.substring(1)),E.forEach(S=>{let T=s.get(S);T||s.set(S,T=new Set),w.split("").forEach(A=>T.add(A))})}:void 0;return Pt(e,f,g,t,b),It(e,f),Y(e)||(yield Ae(e,f,t,b)),yield o==null?void 0:o(f),f}const c=e.cloneNode(!1);return yield Ae(e,c,t),yield o==null?void 0:o(c),c})}function Ne(e){if(e.ownerDocument=void 0,e.ownerWindow=void 0,e.svgStyleElement=void 0,e.svgDefsElement=void 0,e.svgStyles.clear(),e.defaultComputedStyles.clear(),e.sandbox){try{e.sandbox.remove()}catch(t){e.log.warn("Failed to destroyContext",t)}e.sandbox=void 0}e.workers=[],e.fontFamilies.clear(),e.fontCssTexts.clear(),e.requests.clear(),e.tasks=[],e.shadowRoots=[]}function Bt(e){const o=e,{url:t,timeout:r,responseType:n}=o,i=je(o,["url","timeout","responseType"]),a=new AbortController,s=r?setTimeout(()=>a.abort(),r):void 0;return fetch(t,D({signal:a.signal},i)).then(c=>{if(!c.ok)throw new Error("Failed fetch, not 2xx response",{cause:c});switch(n){case"arrayBuffer":return c.arrayBuffer();case"dataUrl":return c.blob().then(gt);case"text":default:return c.text()}}).finally(()=>clearTimeout(s))}function z(e,t){const{url:r,requestType:n="text",responseType:i="text",imageDom:a}=t;let s=r;const{timeout:o,acceptOfImage:c,requests:u,fetchFn:l,fetch:{requestInit:d,bypassingCache:m,placeholderImage:f},font:h,workers:g,fontFamilies:p}=e;n==="image"&&(G||te)&&e.drawImageCount++;let E=u.get(r);if(!E){m&&m instanceof RegExp&&m.test(s)&&(s+=(/\?/.test(s)?"&":"?")+new Date().getTime());const b=n.startsWith("font")&&h&&h.minify,w=new Set;b&&n.split(";")[1].split(",").forEach(F=>{p.has(F)&&p.get(F).forEach(B=>w.add(B))});const S=b&&w.size,T=D({url:s,timeout:o,responseType:S?"arrayBuffer":i,headers:n==="image"?{accept:c}:void 0},d);E={type:n,resolve:void 0,reject:void 0,response:null},E.response=C(this,null,function*(){if(l&&n==="image"){const A=yield l(r);if(A)return A}return!G&&r.startsWith("http")&&g.length?new Promise((A,F)=>{g[u.size&g.length-1].postMessage(D({rawUrl:r},T)),E.resolve=A,E.reject=F}):Bt(T)}).catch(A=>{if(u.delete(r),n==="image"&&f)return e.log.warn("Failed to fetch image base64, trying to use placeholder image",s),typeof f=="string"?f:f(a);throw A}),u.set(r,E)}return E.response}function Re(e,t,r,n){return C(this,null,function*(){if(!Ie(e))return e;for(const[i,a]of $t(e,t))try{const s=yield z(r,{url:a,requestType:n?"image":"text",responseType:"dataUrl"});e=e.replace(Lt(i),`$1${s}$3`)}catch(s){r.log.warn("Failed to fetch css data url",i,s)}return e})}function Ie(e){return/url\((['"]?)([^'"]+?)\1\)/.test(e)}const ke=/url\((['"]?)([^'"]+?)\1\)/g;function $t(e,t){const r=[];return e.replace(ke,(n,i,a)=>(r.push([a,ue(a,t)]),n)),r.filter(([n])=>!re(n))}function Lt(e){const t=e.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${t})(['"]?\\))`,"g")}const xt=["background-image","border-image-source","-webkit-border-image","-webkit-mask-image","list-style-image"];function Mt(e,t){return xt.map(r=>{const n=e.getPropertyValue(r);return!n||n==="none"?null:((G||te)&&t.drawImageCount++,Re(n,null,t,!0).then(i=>{!i||n===i||e.setProperty(r,i,e.getPropertyPriority(r))}))}).filter(Boolean)}function Ot(e,t){if($(e)){const r=e.currentSrc||e.src;if(!re(r))return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.srcset="",e.dataset.originalSrc=r,e.src=n||"")})];(G||te)&&t.drawImageCount++}else if(H(e)&&!re(e.href.baseVal)){const r=e.href.baseVal;return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.dataset.originalSrc=r,e.href.baseVal=n||"")})]}return[]}function jt(e,t){var o;const{ownerDocument:r,svgDefsElement:n}=t,i=(o=e.getAttribute("href"))!=null?o:e.getAttribute("xlink:href");if(!i)return[];const[a,s]=i.split("#");if(s){const c=`#${s}`,u=t.shadowRoots.reduce((l,d)=>l!=null?l:d.querySelector(`svg ${c}`),r==null?void 0:r.querySelector(`svg ${c}`));if(a&&e.setAttribute("href",c),n!=null&&n.querySelector(c))return[];if(u)return n==null||n.appendChild(u.cloneNode(!0)),[];if(a)return[z(t,{url:a,responseType:"text"}).then(l=>{n==null||n.insertAdjacentHTML("beforeend",l)})]}return[]}function De(e,t){const{tasks:r}=t;I(e)&&(($(e)||ce(e))&&r.push(...Ot(e,t)),Qe(e)&&r.push(...jt(e,t))),V(e)&&r.push(...Mt(e.style,t)),e.childNodes.forEach(n=>{De(n,t)})}function qt(e,t){return C(this,null,function*(){const{ownerDocument:r,svgStyleElement:n,fontFamilies:i,fontCssTexts:a,tasks:s,font:o}=t;if(!(!r||!n||!i.size))if(o&&o.cssText){const c=Ue(o.cssText,t);n.appendChild(r.createTextNode(`${c} +`))}else{const c=Array.from(r.styleSheets).filter(f=>{try{return"cssRules"in f&&!!f.cssRules.length}catch(h){return t.log.warn(`Error while reading CSS rules from ${f.href}`,h),!1}}),u=r.implementation.createHTMLDocument(""),l=u.createElement("style");u.head.appendChild(l);const d=l.sheet;yield Promise.all(c.flatMap(f=>Array.from(f.cssRules).map(h=>C(this,null,function*(){if(Je(h)){const g=h.href;let p="";try{p=yield z(t,{url:g,requestType:"text",responseType:"text"})}catch(b){t.log.warn(`Error fetch remote css import from ${g}`,b)}const E=p.replace(ke,(b,w,S)=>b.replace(S,ue(S,g)));for(const b of Ht(E))try{d.insertRule(b,d.cssRules.length)}catch(w){t.log.warn("Error inserting rule from remote css import",{rule:b,error:w})}}})))),d.cssRules.length&&c.push(d);const m=[];c.forEach(f=>{oe(f.cssRules,m)}),m.filter(f=>{var h;return Ye(f)&&Ie(f.style.getPropertyValue("src"))&&((h=we(f.style.getPropertyValue("font-family")))==null?void 0:h.some(g=>i.has(g)))}).forEach(f=>{const h=f,g=a.get(h.cssText);g?n.appendChild(r.createTextNode(`${g} +`)):s.push(Re(h.cssText,h.parentStyleSheet?h.parentStyleSheet.href:null,t).then(p=>{p=Ue(p,t),a.set(h.cssText,p),n.appendChild(r.createTextNode(`${p} +`))}))})}})}const Wt=/(\/\*[\s\S]*?\*\/)/g,Pe=/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi;function Ht(e){if(e==null)return[];const t=[];let r=e.replace(Wt,"");for(;;){const a=Pe.exec(r);if(!a)break;t.push(a[0])}r=r.replace(Pe,"");const n=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,i=new RegExp("((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})","gi");for(;;){let a=n.exec(r);if(a)i.lastIndex=n.lastIndex;else if(a=i.exec(r),a)n.lastIndex=i.lastIndex;else break;t.push(a[0])}return t}const Vt=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,zt=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Ue(e,t){const{font:r}=t,n=r?r==null?void 0:r.preferredFormat:void 0;return n?e.replace(zt,i=>{for(;;){const[a,,s]=Vt.exec(i)||[];if(!s)return"";if(s===n)return`src: ${a};`}}):e}function oe(e,t=[]){for(const r of Array.from(e))Ke(r)?t.push(...oe(r.cssRules)):"cssRules"in r?oe(r.cssRules,t):t.push(r);return t}const Xt=/\bx?link:?href\s*=\s*["'](?!data:)[^"']+["']/i;function Gt(e){return Xt.test(e.innerHTML)}function _e(e,t){return C(this,null,function*(){const r=yield k(e,t);if(I(r.node)&&H(r.node)&&!Gt(r.node))return r.node;const{ownerDocument:n,log:i,tasks:a,svgStyleElement:s,svgDefsElement:o,svgStyles:c,font:u,progress:l,autoDestruct:d,onCloneNode:m,onEmbedNode:f,onCreateForeignObjectSvg:h}=r;i.time("clone node");const g=yield ne(r.node,r,!0);if(s&&n){let S="";c.forEach((T,A)=>{S+=`${T.join(`, +`)} { + ${A} +} +`}),s.appendChild(n.createTextNode(S))}i.timeEnd("clone node"),yield m==null?void 0:m(g),u!==!1&&I(g)&&(i.time("embed web font"),yield qt(g,r),i.timeEnd("embed web font")),i.time("embed node"),De(g,r);const p=a.length;let E=0;const b=()=>C(this,null,function*(){for(;;){const S=a.pop();if(!S)break;try{yield S}catch(T){r.log.warn("Failed to run task",T)}l==null||l(++E,p)}});l==null||l(E,p),yield Promise.all([...Array.from({length:4})].map(b)),i.timeEnd("embed node"),yield f==null?void 0:f(g);const w=Yt(g,r);return o&&w.insertBefore(o,w.children[0]),s&&w.insertBefore(s,w.children[0]),d&&Ne(r),yield h==null?void 0:h(w),w})}function Yt(e,t){const{width:r,height:n}=t,i=fe(r,n,e.ownerDocument),a=i.ownerDocument.createElementNS(i.namespaceURI,"foreignObject");return a.setAttributeNS(null,"x","0%"),a.setAttributeNS(null,"y","0%"),a.setAttributeNS(null,"width","100%"),a.setAttributeNS(null,"height","100%"),a.append(e),i.appendChild(a),i}function Q(e,t){return C(this,null,function*(){var s;const r=yield k(e,t),n=yield _e(r),i=de(n,r.isEnable("removeControlCharacter"));r.autoDestruct||(r.svgStyleElement=be(r.ownerDocument),r.svgDefsElement=(s=r.ownerDocument)==null?void 0:s.createElementNS(K,"defs"),r.svgStyles.clear());const a=_(i,n.ownerDocument);return yield yt(a,r)})}function Jt(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,type:i,quality:a,dpi:s}=r,o=yield Q(r);n.time("canvas to blob");const c=yield ft(o,i,a);if(["image/png","image/jpeg"].includes(i)&&s){const u=yield mt(c.slice(0,33));let l=new Uint8Array(u);return i==="image/png"?l=ae(l,s):i==="image/jpeg"&&(l=v(l,s)),n.timeEnd("canvas to blob"),new Blob([l,c.slice(33)],{type:i})}return n.timeEnd("canvas to blob"),c})}function x(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,quality:i,type:a,dpi:s}=r,o=yield Q(r);n.time("canvas to data url");let c=o.toDataURL(a,i);if(["image/png","image/jpeg"].includes(a)&&s&&ie&&Xe){const[u,l]=c.split(",");let d=0,m=!1;if(a==="image/png"){const w=Ve(l);w>=0?(d=Math.ceil((w+28)/3)*4,m=!0):d=33/3*4}else a==="image/jpeg"&&(d=18/3*4);const f=l.substring(0,d),h=l.substring(d),g=window.atob(f),p=new Uint8Array(g.length);for(let w=0;w mirroring selectedElement's bounding rect. Click (no + // drag) drops a comment pin; drag paints a magenta SVG stroke. All coords + // are stored in element-local CSS px so they survive scroll / resize and + // correlate directly with the captured PNG. + // --------------------------------------------------------------------------- + + const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click + const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it + let annotOverlayEl = null; + let annotSvgEl = null; + let annotPinsEl = null; + let annotClearChipEl = null; + let annotState = { comments: [], strokes: [] }; + let annotActive = false; + // `annotPointer` is either: + // { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin + // { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin + let annotPointer = null; + let annotEditing = null; // { idx, input, wrapEl } + let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete + + function initAnnotOverlay() { + annotOverlayEl = document.createElement('div'); + annotOverlayEl.id = PREFIX + '-annot'; + Object.assign(annotOverlayEl.style, { + position: 'fixed', top: '0', left: '0', width: '0', height: '0', + pointerEvents: 'auto', zIndex: Z.highlight + 2, + display: 'none', overflow: 'visible', + cursor: 'crosshair', touchAction: 'none', + }); + + annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + annotSvgEl.id = PREFIX + '-annot-svg'; + Object.assign(annotSvgEl.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', + // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via + // pointer-events=stroke so gaps still fall through to the overlay. + pointerEvents: 'none', overflow: 'visible', + }); + annotOverlayEl.appendChild(annotSvgEl); + + annotPinsEl = document.createElement('div'); + annotPinsEl.id = PREFIX + '-annot-pins'; + Object.assign(annotPinsEl.style, { + position: 'absolute', inset: '0', + pointerEvents: 'none', + }); + annotOverlayEl.appendChild(annotPinsEl); + + annotClearChipEl = document.createElement('div'); + annotClearChipEl.id = PREFIX + '-annot-clear'; + annotClearChipEl.dataset.annotClear = 'true'; + annotClearChipEl.textContent = 'Clear'; + Object.assign(annotClearChipEl.style, { + position: 'absolute', top: '8px', right: '8px', + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '10px', fontWeight: '500', + letterSpacing: '0.08em', textTransform: 'uppercase', + padding: '5px 12px', borderRadius: '999px', + cursor: 'pointer', pointerEvents: 'auto', + display: 'none', userSelect: 'none', + boxShadow: '0 1px 3px rgba(0,0,0,0.2)', + }); + annotOverlayEl.appendChild(annotClearChipEl); + + annotOverlayEl.addEventListener('pointerdown', onAnnotDown); + annotOverlayEl.addEventListener('pointermove', onAnnotMove); + annotOverlayEl.addEventListener('pointerup', onAnnotUp); + annotOverlayEl.addEventListener('pointercancel', onAnnotUp); + document.body.appendChild(annotOverlayEl); + } + + function updateClearChip() { + if (!annotClearChipEl) return; + const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0; + annotClearChipEl.style.display = hasAny ? 'block' : 'none'; + } + + function showAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + annotActive = true; + positionAnnotOverlay(el); + annotOverlayEl.style.display = 'block'; + } + + function hideAnnotOverlay() { + annotActive = false; + if (annotOverlayEl) annotOverlayEl.style.display = 'none'; + // Drop any in-progress edit without touching annotState — clearAnnotations + // (if the caller is exiting configure mode) handles state reset. + annotEditing = null; + } + + function positionAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + const r = el.getBoundingClientRect(); + Object.assign(annotOverlayEl.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height); + } + + function clearAnnotations() { + annotState.comments = []; + annotState.strokes = []; + if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + if (annotPinsEl) annotPinsEl.innerHTML = ''; + annotPointer = null; + annotEditing = null; + annotLastPinClick = { idx: -1, time: 0 }; + updateClearChip(); + } + + // Rebuild the SVG layer. Each stroke gets a wider invisible hit path + // beneath the visible magenta path so clicks register on thin lines. + function redrawStrokes() { + while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + annotState.strokes.forEach((s, idx) => { + const d = pointsToPath(s.points); + const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + hit.setAttribute('d', d); + hit.setAttribute('stroke', 'transparent'); + hit.setAttribute('stroke-width', '16'); + hit.setAttribute('stroke-linecap', 'round'); + hit.setAttribute('stroke-linejoin', 'round'); + hit.setAttribute('fill', 'none'); + hit.setAttribute('pointer-events', 'stroke'); + hit.style.cursor = 'pointer'; + hit.dataset.annotStroke = String(idx); + annotSvgEl.appendChild(hit); + const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + visible.setAttribute('d', d); + visible.setAttribute('stroke', C.brand); + visible.setAttribute('stroke-width', '3'); + visible.setAttribute('stroke-linecap', 'round'); + visible.setAttribute('stroke-linejoin', 'round'); + visible.setAttribute('fill', 'none'); + visible.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(visible); + }); + updateClearChip(); + } + + function localCoords(e) { + const rect = annotOverlayEl.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + function onAnnotDown(e) { + if (!annotActive) return; + + // 1) Clear chip → wipe all annotations + if (e.target.closest?.('[data-annot-clear]')) { + if (annotEditing) annotEditing = null; + clearAnnotations(); + renderAllPins(); + redrawStrokes(); + e.stopPropagation(); e.preventDefault(); + return; + } + + // 2) Stroke hit path → delete that stroke + const strokeHit = e.target.closest?.('[data-annot-stroke]'); + if (strokeHit) { + const idx = parseInt(strokeHit.dataset.annotStroke, 10); + if (Number.isInteger(idx)) { + annotState.strokes.splice(idx, 1); + redrawStrokes(); + } + e.stopPropagation(); e.preventDefault(); + return; + } + + // 3) Pin → drag, edit, or delete-on-double-click + const pinWrap = e.target.closest?.('[data-annot-pin]'); + if (pinWrap) { + const idx = parseInt(pinWrap.dataset.annotPin, 10); + if (!Number.isInteger(idx)) return; + // Double-click (two pointerdowns on the same pin within window) → delete. + const now = Date.now(); + if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) { + if (annotEditing && annotEditing.idx === idx) annotEditing = null; + annotState.comments.splice(idx, 1); + annotLastPinClick = { idx: -1, time: 0 }; + renderAllPins(); + e.stopPropagation(); e.preventDefault(); + return; + } + annotLastPinClick = { idx, time: now }; + // If editing a different pin, commit that edit before starting here. + if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin(); + // If already editing THIS pin and the user clicked the dot, let the + // input keep focus (don't start a drag — the click wasn't meant as one). + if (annotEditing && annotEditing.idx === idx) return; + const p = localCoords(e); + const pin = annotState.comments[idx]; + annotPointer = { + kind: 'pin', idx, + startPointer: p, + startPin: { x: pin.x, y: pin.y }, + moved: false, + }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + return; + } + + // 4) Empty area → commit any open edit, then start new annotation + if (annotEditing) { + finalizeEditingPin(); + e.stopPropagation(); e.preventDefault(); + return; + } + const p = localCoords(e); + annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + } + + function onAnnotMove(e) { + if (!annotActive || !annotPointer) return; + const p = localCoords(e); + + if (annotPointer.kind === 'pin') { + const dx = p.x - annotPointer.startPointer.x; + const dy = p.y - annotPointer.startPointer.y; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + } + const pin = annotState.comments[annotPointer.idx]; + if (!pin) { annotPointer = null; return; } + pin.x = annotPointer.startPin.x + dx; + pin.y = annotPointer.startPin.y + dy; + renderAllPins(); + e.stopPropagation(); + return; + } + + // kind === 'new' + const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + strokeEl.setAttribute('stroke', C.brand); + strokeEl.setAttribute('stroke-width', '3'); + strokeEl.setAttribute('stroke-linecap', 'round'); + strokeEl.setAttribute('stroke-linejoin', 'round'); + strokeEl.setAttribute('fill', 'none'); + strokeEl.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(strokeEl); + annotPointer.strokeEl = strokeEl; + annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]]; + } + annotPointer.strokePoints.push([p.x, p.y]); + annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints)); + e.stopPropagation(); + } + + function onAnnotUp(e) { + if (!annotActive || !annotPointer) return; + + if (annotPointer.kind === 'pin') { + const wasDrag = annotPointer.moved; + const idx = annotPointer.idx; + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + if (wasDrag) { + // A drag is an intentional reposition; a follow-up click shouldn't be + // interpreted as a double-click-to-delete. + annotLastPinClick = { idx: -1, time: 0 }; + } else { + beginEditPin(idx); + } + e.stopPropagation(); + return; + } + + // kind === 'new' + const wasDrag = annotPointer.moved; + if (wasDrag) { + annotState.strokes.push({ points: annotPointer.strokePoints }); + // Swap the temporary preview SVG path for the full render with hit paths. + redrawStrokes(); + } else { + const idx = annotState.comments.length; + annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' }); + renderAllPins(); + beginEditPin(idx); + } + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + e.stopPropagation(); + } + + function pointsToPath(points) { + if (!points || points.length === 0) return ''; + let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1); + for (let i = 1; i < points.length; i++) { + d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1); + } + return d; + } + + function renderAllPins() { + annotPinsEl.innerHTML = ''; + annotState.comments.forEach((c, idx) => { + annotPinsEl.appendChild(buildPinElement(c, idx)); + }); + updateClearChip(); + } + + function buildPinElement(comment, idx) { + const interactive = idx >= 0; + const wrap = document.createElement('div'); + if (interactive) wrap.dataset.annotPin = String(idx); + Object.assign(wrap.style, { + position: 'absolute', + left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px', + pointerEvents: interactive ? 'auto' : 'none', + display: 'flex', alignItems: 'flex-start', gap: '6px', + cursor: interactive ? 'grab' : 'default', + touchAction: 'none', + }); + const dot = document.createElement('div'); + Object.assign(dot.style, { + width: '14px', height: '14px', borderRadius: '50%', + background: C.brand, border: '2px solid ' + C.white, + boxShadow: '0 1px 3px rgba(0,0,0,0.25)', + flexShrink: '0', + }); + wrap.appendChild(dot); + + if (comment.text) { + const bubble = document.createElement('div'); + bubble.textContent = comment.text; + Object.assign(bubble.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + marginTop: '-2px', maxWidth: '220px', + pointerEvents: 'none', whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }); + wrap.appendChild(bubble); + } + return wrap; + } + + function beginEditPin(idx) { + const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]'); + if (!wrapEl) return; + // Strip any existing bubble (but keep the dot) + wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove()); + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Note…'; + Object.assign(input.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + border: '1px solid ' + C.brand, + outline: 'none', marginTop: '-2px', + width: '220px', pointerEvents: 'auto', + }); + const originalText = annotState.comments[idx].text || ''; + input.value = originalText; + wrapEl.appendChild(input); + annotEditing = { idx, input, wrapEl, originalText }; + input.addEventListener('keydown', onAnnotInputKey, true); + input.addEventListener('blur', () => { + // Fires on both focus-loss and programmatic blur; commit unless we + // already handled it. + if (annotEditing && annotEditing.input === input) finalizeEditingPin(); + }); + // Stop clicks/pointerdowns inside the input from bubbling to the overlay + ['pointerdown', 'click'].forEach(ev => { + input.addEventListener(ev, e => e.stopPropagation()); + }); + setTimeout(() => input.focus(), 0); + } + + function onAnnotInputKey(e) { + if (e.key === 'Enter') { + e.preventDefault(); e.stopPropagation(); + finalizeEditingPin(); + } else if (e.key === 'Escape') { + e.preventDefault(); e.stopPropagation(); + cancelEditingPin(); + } else { + // Keep arrows / backspace from hitting global handlers + e.stopPropagation(); + } + } + + function finalizeEditingPin() { + if (!annotEditing) return; + const { idx, input } = annotEditing; + const text = input.value.trim(); + annotEditing = null; + if (text) annotState.comments[idx].text = text; + else annotState.comments.splice(idx, 1); + renderAllPins(); + } + + function cancelEditingPin() { + if (!annotEditing) return; + const { idx, originalText } = annotEditing; + annotEditing = null; + // If the pin had text before this edit, revert to it. If it was a + // just-created empty pin, Escape removes it. + if (originalText) { + annotState.comments[idx].text = originalText; + } else { + annotState.comments.splice(idx, 1); + } + renderAllPins(); + } + + // Build a detached annotation subtree suitable for injection into the clone + // modern-screenshot creates. Coordinates are element-local so this slots + // straight into an element that's been made position:relative. Takes an + // explicit snapshot so it works after annotState has been cleared. + function buildAnnotationsForCapture(rect, snapshot) { + const comments = snapshot ? snapshot.comments : annotState.comments; + const strokes = snapshot ? snapshot.strokes : annotState.strokes; + if (comments.length === 0 && strokes.length === 0) return null; + const wrap = document.createElement('div'); + Object.assign(wrap.style, { + position: 'absolute', top: '0', left: '0', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', overflow: 'visible', + }); + if (strokes.length > 0) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height); + Object.assign(svg.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', overflow: 'visible', + }); + for (const s of strokes) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('stroke', C.brand); + path.setAttribute('stroke-width', '3'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('fill', 'none'); + path.setAttribute('d', pointsToPath(s.points)); + svg.appendChild(path); + } + wrap.appendChild(svg); + } + for (const c of comments) { + // idx=-1 means non-interactive; pointerEvents stay off in the clone + wrap.appendChild(buildPinElement(c, -1)); + } + return wrap; } // --------------------------------------------------------------------------- @@ -416,8 +901,10 @@ fontSize: '11px', color: C.ash, whiteSpace: 'nowrap', marginLeft: 'auto', }); + // Variants currently arrive atomically in a single file edit, so a + // per-variant counter would lie. Say what's true. status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + (arrivedVariants + 1) + ' of ' + expectedVariants + '...' + ? 'Generating ' + expectedVariants + ' variants...' : 'Done'; row.appendChild(status); @@ -838,6 +1325,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + hideShaderOverlay(); updateBarContent('cycling'); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -860,6 +1348,10 @@ positionBar(); showHighlight(selectedElement); } + if (annotActive) positionAnnotOverlay(selectedElement); + // Shader overlay (via debug P toggle or generation) is repositioned + // by its own branch below; debug no longer has a separate overlay. + if (shaderState) positionShaderOverlay(); scrollRaf = requestAnimationFrame(tick); } scrollRaf = requestAnimationFrame(tick); @@ -937,6 +1429,8 @@ } hideBar(); hideHighlight(); + hideShaderOverlay(); + hideAnnotOverlay(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } clearSession(); @@ -976,6 +1470,8 @@ if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { hideBar(); stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -989,15 +1485,19 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); } function handleKeyDown(e) { + // When the annotation input is focused, let it handle its own keys. + if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); state = 'PICKING'; return; } + if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { hideHighlight(); state = 'IDLE'; return; } @@ -1024,6 +1524,8 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); return; @@ -1035,6 +1537,8 @@ } else { // CONFIGURING: re-select the new element and refresh the bar selectedElement = next; + clearAnnotations(); + showAnnotOverlay(next); showBar('configure'); startScrollTracking(); } @@ -1056,25 +1560,408 @@ const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; + // Commit any pending pin edit BEFORE we snapshot annotations. + if (annotEditing) finalizeEditingPin(); + currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; - sendEvent({ + // Flip to GENERATING immediately so the bar morphs without waiting on + // capture + upload. The event is emitted from captureAndEmit() once the + // screenshot is uploaded (or capture fails — we still emit, just without + // screenshotPath). + const elForCapture = selectedElement; + const captureRect = elForCapture.getBoundingClientRect(); + const snapshot = { + comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), + strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })), + }; + const basePayload = { type: 'generate', id: currentSessionId, action: selectedAction, freeformPrompt: prompt || undefined, count: selectedCount, pageUrl: location.pathname, - element: extractContext(selectedElement), - }); + element: extractContext(elForCapture), + }; + if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments; + if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes; + + // Hide the interactive overlay so it doesn't linger during generation. + hideAnnotOverlay(); + clearAnnotations(); state = 'GENERATING'; showBar('generating'); saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + + captureAndEmit(elForCapture, basePayload, snapshot, captureRect); + } + + // --------------------------------------------------------------------------- + // Screenshot capture + upload + // --------------------------------------------------------------------------- + + let msLoadPromise = null; + function loadModernScreenshot() { + if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot); + if (msLoadPromise) return msLoadPromise; + msLoadPromise = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; + s.onload = () => resolve(window.modernScreenshot); + s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; + document.head.appendChild(s); + }); + return msLoadPromise; + } + + // Collect @font-face rules from every stylesheet on the page. Cross-origin + // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules + // access, so modern-screenshot can't embed them on its own — the resulting + // SVG falls back to system fonts and text re-wraps + renders with different + // weight. We fetch the raw CSS text (CORS-permitted for these providers), + // extract @font-face blocks, inline the referenced font files as base64 + // data URIs (SVGs rasterized via canvas can't fetch external resources, + // so URLs inside the SVG silently fail without this), and pass the result + // to modern-screenshot as font.cssText. + const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i; + const FONT_MIME = { + woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject', + }; + function bufferToBase64(buf) { + const bytes = new Uint8Array(buf); + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); + } + async function inlineFontUrls(cssText) { + const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g; + const urls = new Set(); + let m; + while ((m = urlRe.exec(cssText))) { + if (FONT_EXT_RE.test(m[2])) urls.add(m[2]); + } + const map = new Map(); + await Promise.all([...urls].map(async (url) => { + try { + const res = await fetch(url); + if (!res.ok) return; + const buf = await res.arrayBuffer(); + const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2'; + const mime = FONT_MIME[ext] || 'application/octet-stream'; + map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf)); + } catch { /* skip; fall through to URL */ } + })); + return cssText.replace(urlRe, (orig, q, url) => { + const data = map.get(url); + return data ? 'url(' + q + data + q + ')' : orig; + }); + } + async function collectFontCssText() { + const chunks = []; + const fontFaceRe = /@font-face\s*\{[^}]*\}/g; + for (const sheet of document.styleSheets) { + try { + const rules = sheet.cssRules; + for (const rule of rules) { + if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) { + chunks.push(rule.cssText); + } + } + } catch { + if (!sheet.href) continue; + try { + const res = await fetch(sheet.href); + if (!res.ok) continue; + const text = await res.text(); + let m2; + while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]); + } catch { /* ignore; capture is best-effort */ } + } + } + if (chunks.length === 0) return ''; + return inlineFontUrls(chunks.join('\n')); + } + + // Capture the element (with current annotations baked in) and return a PNG + // Blob. Shared between the Go flow (uploads it to the server) and the + // debug toggle (displays it as an overlay for side-by-side comparison). + async function captureElementToBlob(el, snapshot, rect) { + try { if (document.fonts?.ready) await document.fonts.ready; } catch {} + const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); + let annotNode = null; + let savedPosition = null; + if (hasAnnotations) { + const pos = getComputedStyle(el).position; + if (pos === 'static') { + savedPosition = el.style.position; + el.style.position = 'relative'; + } + annotNode = buildAnnotationsForCapture(rect, snapshot); + el.appendChild(annotNode); + } + try { + const ms = await loadModernScreenshot(); + const fontCssText = await collectFontCssText(); + return await ms.domToBlob(el, { + scale: Math.min(window.devicePixelRatio || 1, 2), + backgroundColor: getComputedStyle(document.body).backgroundColor || '#ffffff', + font: fontCssText ? { cssText: fontCssText } : undefined, + }); + } finally { + if (annotNode) annotNode.remove(); + if (savedPosition !== null) el.style.position = savedPosition; + } + } + + async function captureAndEmit(el, basePayload, snapshot, rect) { + let screenshotPath; + let blob; + try { + blob = await captureElementToBlob(el, snapshot, rect); + } catch (err) { + console.warn('[impeccable] capture failed, proceeding without screenshot:', err); + } + // Light up the shader overlay the moment capture is ready — no reason to + // wait for the upload to complete before the user sees something alive. + if (blob && state === 'GENERATING') { + showShaderOverlay(el, blob, rect); + } + if (blob) { + try { + const uploadRes = await fetch( + 'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) + + '&eventId=' + encodeURIComponent(basePayload.id), + { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob }, + ); + if (uploadRes.ok) { + const { path: p } = await uploadRes.json(); + screenshotPath = p; + } else { + console.warn('[impeccable] annotation upload failed:', uploadRes.status); + } + } catch (err) { + console.warn('[impeccable] annotation upload failed:', err); + } + } + sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); + } + + // --------------------------------------------------------------------------- + // Shader overlay — renders the captured screenshot as a WebGL texture and + // runs an editorial "ink-wash" fragment shader over it during generation. + // A single rolling band sweeps top-to-bottom, desaturating + tinting magenta + // and leaving a soft trail. Makes the wait feel like a letterpress scan + // instead of a dead spinner. + // --------------------------------------------------------------------------- + + const SHADER_VS = `attribute vec2 a_position; +attribute vec2 a_uv; +varying vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_position, 0.0, 1.0); +}`; + + const SHADER_FS = `precision highp float; +uniform sampler2D u_texture; +uniform float u_time; +uniform vec2 u_resolution; +uniform vec3 u_accent; +varying vec2 v_uv; + +// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at +// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean +// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below" +// failure that reversed-edge smoothstep would give). +float bandAt(float d, float leadW, float trailW) { + float above = smoothstep(-leadW, 0.0, d); + float below = 1.0 - smoothstep(0.0, trailW, d); + return above * below; +} + +void main() { + vec2 uv = v_uv; + // Roller sweeps top-to-bottom with small overshoot so each cycle enters + // and exits the element cleanly. + float phase = fract(u_time / 3.4); + float y = phase * 1.25 - 0.12; + float band = bandAt(uv.y - y, 0.05, 0.32); + + // Halftone cell grid (fixed ~10 px pitch). + float cellPx = 10.0; + vec2 gridUv = uv * u_resolution / cellPx; + vec2 cellId = floor(gridUv); + vec2 cellUv = fract(gridUv) - 0.5; + vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution; + vec3 cellImg = texture2D(u_texture, sampleCenter).rgb; + float luma = dot(cellImg, vec3(0.299, 0.587, 0.114)); + // Darker cells → bigger magenta dots (classic risograph halftone curve). + float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56; + float dotMask = smoothstep(radius + 0.06, radius, length(cellUv)); + vec3 paper = vec3(0.975, 0.965, 0.955); + vec3 dotLayer = mix(paper, u_accent, dotMask); + + // Blend the halftone layer in where the roller is passing; leave the + // element pristine elsewhere. + vec3 base = texture2D(u_texture, uv).rgb; + gl_FragColor = vec4(mix(base, dotLayer, band), 1.0); +}`; + + // Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350)) + const SHADER_ACCENT = [0.82, 0.16, 0.47]; + let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + + function compileShader(gl, type, source) { + const sh = gl.createShader(type); + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(sh); + gl.deleteShader(sh); + throw new Error('shader compile failed: ' + info); + } + return sh; + } + + function positionShaderOverlay() { + if (!shaderState || !selectedElement) return; + const r = selectedElement.getBoundingClientRect(); + Object.assign(shaderState.canvas.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + } + + function hideShaderOverlay() { + if (!shaderState) return; + if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); + if (shaderState.canvas) shaderState.canvas.remove(); + const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + shaderState = null; + } + + async function showShaderOverlay(el, blob, rect) { + hideShaderOverlay(); + if (!blob || !el) return; + const canvas = document.createElement('canvas'); + canvas.id = PREFIX + '-shader'; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + Object.assign(canvas.style, { + position: 'fixed', + top: rect.top + 'px', left: rect.left + 'px', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', + zIndex: Z.bar - 1, + }); + document.body.appendChild(canvas); + + const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) + || canvas.getContext('experimental-webgl'); + if (!gl) { + // WebGL unavailable — fall back to a plain overlay so the user + // still sees something meaningful during generation. + canvas.remove(); + const img = document.createElement('img'); + img.src = URL.createObjectURL(blob); + img.id = PREFIX + '-shader'; + Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' }); + document.body.appendChild(img); + shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 }; + return; + } + + let program, texture; + try { + const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS); + program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error('program link failed: ' + gl.getProgramInfoLog(program)); + } + // Full-screen quad + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + -1, -1, 0, 1, + 1, -1, 1, 1, + -1, 1, 0, 0, + -1, 1, 0, 0, + 1, -1, 1, 1, + 1, 1, 1, 0, + ]), gl.STATIC_DRAW); + const posLoc = gl.getAttribLocation(program, 'a_position'); + const uvLoc = gl.getAttribLocation(program, 'a_uv'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(uvLoc); + gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8); + } catch (err) { + console.warn('[impeccable] shader setup failed:', err); + canvas.remove(); + return; + } + + // Upload the screenshot as a texture + let bitmap; + try { + bitmap = await createImageBitmap(blob); + } catch { + // Safari fallback: go via a regular Image + const imgUrl = URL.createObjectURL(blob); + const img = new Image(); + img.src = imgUrl; + await new Promise((r, rej) => { img.onload = r; img.onerror = rej; }); + bitmap = img; + URL.revokeObjectURL(imgUrl); + } + texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + if (bitmap.close) bitmap.close(); + + const uTime = gl.getUniformLocation(program, 'u_time'); + const uRes = gl.getUniformLocation(program, 'u_resolution'); + const uAccent = gl.getUniformLocation(program, 'u_accent'); + const uTex = gl.getUniformLocation(program, 'u_texture'); + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; + function frame() { + if (!shaderState) return; + const elapsed = (performance.now() - shaderState.startTime) / 1000; + const t = shaderState.reduced ? 0.0 : elapsed; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.useProgram(program); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.uniform1i(uTex, 0); + gl.uniform1f(uTime, t); + gl.uniform2f(uRes, canvas.width, canvas.height); + gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]); + gl.drawArrays(gl.TRIANGLES, 0, 6); + shaderState.rafId = requestAnimationFrame(frame); + } + frame(); } function handleAccept() { @@ -2713,6 +3600,7 @@ function init() { initHighlight(); + initAnnotOverlay(); initBar(); initActionPicker(); initGlobalBar(); diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs index 109aa768e..97163b255 100644 --- a/.pi/skills/impeccable/scripts/live-server.mjs +++ b/.pi/skills/impeccable/scripts/live-server.mjs @@ -18,7 +18,6 @@ import { randomUUID } from 'node:crypto'; import { spawn, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; @@ -56,8 +55,13 @@ const state = { pendingEvents: [], // browser events waiting for agent poll pendingPolls: [], // agent poll callbacks waiting for browser events exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots }; +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + function enqueueEvent(event) { if (state.pendingPolls.length > 0) { state.pendingPolls.shift()(event); @@ -134,6 +138,10 @@ function validateEvent(msg) { if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; return null; case 'accept': if (!msg.id) return 'accept: missing id'; @@ -175,6 +183,83 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { return; } + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + // --- Health --- if (p === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -250,10 +335,11 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } const absPath = path.resolve(process.cwd(), filePath); if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } - try { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(fs.readFileSync(absPath, 'utf-8')); - } catch { res.writeHead(404); res.end('File not found'); } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); return; } @@ -411,6 +497,9 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } for (const res of state.sseClients) { try { res.end(); } catch {} } state.sseClients.clear(); for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); @@ -442,12 +531,14 @@ Options: --help Show this help Endpoints: - /live.js Browser script (element picker + variant cycling) - /detect.js Detection overlay (backwards compatible) - /events SSE stream (server→browser) + POST (browser→server) - /poll Long-poll for agent CLI - /source Raw source file reader (no-HMR fallback) - /health Health check`); + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); process.exit(0); } @@ -531,6 +622,12 @@ try { state.token = randomUUID(); const portArg = args.find(a => a.startsWith('--port=')); state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); const { detectScript, liveScript } = loadBrowserScripts(); const liveScriptWithToken = diff --git a/.pi/skills/impeccable/scripts/modern-screenshot.umd.js b/.pi/skills/impeccable/scripts/modern-screenshot.umd.js new file mode 100644 index 000000000..a9c5208f6 --- /dev/null +++ b/.pi/skills/impeccable/scripts/modern-screenshot.umd.js @@ -0,0 +1,14 @@ +(function(y,v){typeof exports=="object"&&typeof module!="undefined"?v(exports):typeof define=="function"&&define.amd?define(["exports"],v):(y=typeof globalThis!="undefined"?globalThis:y||self,v(y.modernScreenshot={}))})(this,function(y){"use strict";var rr=Object.defineProperty,nr=Object.defineProperties;var or=Object.getOwnPropertyDescriptors;var Z=Object.getOwnPropertySymbols;var xe=Object.prototype.hasOwnProperty,Me=Object.prototype.propertyIsEnumerable;var Oe=Math.pow,Le=(y,v,N)=>v in y?rr(y,v,{enumerable:!0,configurable:!0,writable:!0,value:N}):y[v]=N,D=(y,v)=>{for(var N in v||(v={}))xe.call(v,N)&&Le(y,N,v[N]);if(Z)for(var N of Z(v))Me.call(v,N)&&Le(y,N,v[N]);return y},M=(y,v)=>nr(y,or(v));var je=(y,v)=>{var N={};for(var R in y)xe.call(y,R)&&v.indexOf(R)<0&&(N[R]=y[R]);if(y!=null&&Z)for(var R of Z(y))v.indexOf(R)<0&&Me.call(y,R)&&(N[R]=y[R]);return N};var C=(y,v,N)=>new Promise((R,O)=>{var X=P=>{try{q(N.next(P))}catch(W){O(W)}},j=P=>{try{q(N.throw(P))}catch(W){O(W)}},q=P=>P.done?R(P.value):Promise.resolve(P.value).then(X,j);q((N=N.apply(y,v)).next())});var Be;function v(e,t){return e[13]=1,e[14]=t>>8,e[15]=t&255,e[16]=t>>8,e[17]=t&255,e}const N=112,R=72,O=89,X=115;let j;function q(){const e=new Int32Array(256);for(let t=0;t<256;t++){let r=t;for(let n=0;n<8;n++)r=r&1?3988292384^r>>>1:r>>>1;e[t]=r}return e}function P(e){let t=-1;j||(j=q());for(let r=0;r>>8;return t^-1}function W(e){const t=e.length-1;for(let r=t;r>=4;r--)if(e[r-4]===9&&e[r-3]===N&&e[r-2]===R&&e[r-1]===O&&e[r]===X)return r-3;return 0}function ae(e,t,r=!1){const n=new Uint8Array(13);t*=39.3701,n[0]=N,n[1]=R,n[2]=O,n[3]=X,n[4]=t>>>24,n[5]=t>>>16,n[6]=t>>>8,n[7]=t&255,n[8]=n[4],n[9]=n[5],n[10]=n[6],n[11]=n[7],n[12]=1;const i=P(n),a=new Uint8Array(4);if(a[0]=i>>>24,a[1]=i>>>16,a[2]=i>>>8,a[3]=i&255,r){const s=W(e);return e.set(n,s),e.set(a,s+13),e}else{const s=new Uint8Array(4);s[0]=0,s[1]=0,s[2]=0,s[3]=9;const o=new Uint8Array(54);return o.set(e,0),o.set(s,33),o.set(n,37),o.set(a,50),o}}const qe="AAlwSFlz",We="AAAJcEhZ",He="AAAACXBI";function Ve(e){let t=e.indexOf(qe);return t===-1&&(t=e.indexOf(We)),t===-1&&(t=e.indexOf(He)),t}const se="[modern-screenshot]",U=typeof window!="undefined",ze=U&&"Worker"in window,ie=U&&"atob"in window,Xe=U&&"btoa"in window,ee=U?(Be=window.navigator)==null?void 0:Be.userAgent:"",le=ee.includes("Chrome"),G=ee.includes("AppleWebKit")&&!le,te=ee.includes("Firefox"),Ge=e=>e&&"__CONTEXT__"in e,Ye=e=>e.constructor.name==="CSSFontFaceRule",Je=e=>e.constructor.name==="CSSImportRule",Ke=e=>e.constructor.name==="CSSLayerBlockRule",I=e=>e.nodeType===1,H=e=>typeof e.className=="object",ce=e=>e.tagName==="image",Qe=e=>e.tagName==="use",V=e=>I(e)&&typeof e.style!="undefined"&&!H(e),Ze=e=>e.nodeType===8,et=e=>e.nodeType===3,$=e=>e.tagName==="IMG",Y=e=>e.tagName==="VIDEO",tt=e=>e.tagName==="CANVAS",rt=e=>e.tagName==="TEXTAREA",nt=e=>e.tagName==="INPUT",ot=e=>e.tagName==="STYLE",at=e=>e.tagName==="SCRIPT",st=e=>e.tagName==="SELECT",it=e=>e.tagName==="SLOT",lt=e=>e.tagName==="IFRAME",ct=(...e)=>console.warn(se,...e);function ut(e){var r;const t=(r=e==null?void 0:e.createElement)==null?void 0:r.call(e,"canvas");return t&&(t.height=t.width=1),!!t&&"toDataURL"in t&&!!t.toDataURL("image/webp").includes("image/webp")}const re=e=>e.startsWith("data:");function ue(e,t){if(e.match(/^[a-z]+:\/\//i))return e;if(U&&e.match(/^\/\//))return window.location.protocol+e;if(e.match(/^[a-z]+:/i)||!U)return e;const r=J().implementation.createHTMLDocument(),n=r.createElement("base"),i=r.createElement("a");return r.head.appendChild(n),r.body.appendChild(i),t&&(n.href=t),i.href=e,i.href}function J(e){var t;return(t=e&&I(e)?e==null?void 0:e.ownerDocument:e)!=null?t:window.document}const K="http://www.w3.org/2000/svg";function fe(e,t,r){const n=J(r).createElementNS(K,"svg");return n.setAttributeNS(null,"width",e.toString()),n.setAttributeNS(null,"height",t.toString()),n.setAttributeNS(null,"viewBox",`0 0 ${e} ${t}`),n}function de(e,t){let r=new XMLSerializer().serializeToString(e);return t&&(r=r.replace(/[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu,"")),`data:image/svg+xml;charset=utf-8,${encodeURIComponent(r)}`}function ft(e,t="image/png",r=1){return C(this,null,function*(){try{return yield new Promise((n,i)=>{e.toBlob(a=>{a?n(a):i(new Error("Blob is null"))},t,r)})}catch(n){if(ie)return dt(e.toDataURL(t,r));throw n}})}function dt(e){var o,c;const[t,r]=e.split(","),n=(c=(o=t.match(/data:(.+);/))==null?void 0:o[1])!=null?c:void 0,i=window.atob(r),a=i.length,s=new Uint8Array(a);for(let u=0;u{const i=new FileReader;i.onload=()=>r(i.result),i.onerror=()=>n(i.error),i.onabort=()=>n(new Error(`Failed read blob to ${t}`)),t==="dataUrl"?i.readAsDataURL(e):t==="arrayBuffer"&&i.readAsArrayBuffer(e)})}const gt=e=>ge(e,"dataUrl"),mt=e=>ge(e,"arrayBuffer");function _(e,t){const r=J(t).createElement("img");return r.decoding="sync",r.loading="eager",r.src=e,r}function L(e,t){return new Promise(r=>{const{timeout:n,ownerDocument:i,onError:a,onWarn:s}=t!=null?t:{},o=typeof e=="string"?_(e,J(i)):e;let c=null,u=null;function l(){r(o),c&&clearTimeout(c),u==null||u()}if(n&&(c=setTimeout(l,n)),Y(o)){const d=o.currentSrc||o.src;if(!d)return o.poster?L(o.poster,t).then(r):l();if(o.readyState>=2)return l();const m=l,f=h=>{s==null||s("Failed video load",d,h),a==null||a(h),l()};u=()=>{o.removeEventListener("loadeddata",m),o.removeEventListener("error",f)},o.addEventListener("loadeddata",m,{once:!0}),o.addEventListener("error",f,{once:!0})}else{const d=ce(o)?o.href.baseVal:o.currentSrc||o.src;if(!d)return l();const m=()=>C(this,null,function*(){if($(o)&&"decode"in o)try{yield o.decode()}catch(h){s==null||s("Failed to decode image, trying to render anyway",o.dataset.originalSrc||d,h)}l()}),f=h=>{s==null||s("Failed image load",o.dataset.originalSrc||d,h),l()};if($(o)&&o.complete)return m();u=()=>{o.removeEventListener("load",m),o.removeEventListener("error",f)},o.addEventListener("load",m,{once:!0}),o.addEventListener("error",f,{once:!0})}})}function me(e,t){return C(this,null,function*(){V(e)&&($(e)||Y(e)?yield L(e,t):yield Promise.all(["img","video"].flatMap(r=>Array.from(e.querySelectorAll(r)).map(n=>L(n,t)))))})}const he=function(){let t=0;const r=()=>`0000${(Math.random()*Oe(36,4)<<0).toString(36)}`.slice(-4);return()=>(t+=1,`u${r()}${t}`)}();function we(e){return e==null?void 0:e.split(",").map(t=>t.trim().replace(/"|'/g,"").toLowerCase()).filter(Boolean)}let pe=0;function ht(e){const t=`${se}[#${pe}]`;return pe++,{time:r=>e&&console.time(`${t} ${r}`),timeEnd:r=>e&&console.timeEnd(`${t} ${r}`),warn:(...r)=>e&&ct(...r)}}function wt(e){return{cache:e?"no-cache":"force-cache"}}function k(e,t){return C(this,null,function*(){return Ge(e)?e:ye(e,M(D({},t),{autoDestruct:!0}))})}function ye(e,t){return C(this,null,function*(){var f,h,g,p,E;const{scale:r=1,workerUrl:n,workerNumber:i=1}=t||{},a=!!(t!=null&&t.debug),s=(f=t==null?void 0:t.features)!=null?f:!0,o=(h=e.ownerDocument)!=null?h:U?window.document:void 0,c=(p=(g=e.ownerDocument)==null?void 0:g.defaultView)!=null?p:U?window:void 0,u=new Map,l=M(D({width:0,height:0,quality:1,type:"image/png",scale:r,backgroundColor:null,style:null,filter:null,maximumCanvasSize:0,timeout:3e4,progress:null,debug:a,fetch:D({requestInit:wt((E=t==null?void 0:t.fetch)==null?void 0:E.bypassingCache),placeholderImage:"data:image/png;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",bypassingCache:!1},t==null?void 0:t.fetch),fetchFn:null,font:{},drawImageInterval:100,workerUrl:null,workerNumber:i,onCloneEachNode:null,onCloneNode:null,onEmbedNode:null,onCreateForeignObjectSvg:null,includeStyleProperties:null,autoDestruct:!1},t),{__CONTEXT__:!0,log:ht(a),node:e,ownerDocument:o,ownerWindow:c,dpi:r===1?null:96*r,svgStyleElement:be(o),svgDefsElement:o==null?void 0:o.createElementNS(K,"defs"),svgStyles:new Map,defaultComputedStyles:new Map,workers:[...Array.from({length:ze&&n&&i?i:0})].map(()=>{try{const b=new Worker(n);return b.onmessage=w=>C(this,null,function*(){var A,F,B,$e;const{url:S,result:T}=w.data;T?(F=(A=u.get(S))==null?void 0:A.resolve)==null||F.call(A,T):($e=(B=u.get(S))==null?void 0:B.reject)==null||$e.call(B,new Error(`Error receiving message from worker: ${S}`))}),b.onmessageerror=w=>{var T,A;const{url:S}=w.data;(A=(T=u.get(S))==null?void 0:T.reject)==null||A.call(T,new Error(`Error receiving message from worker: ${S}`))},b}catch(b){return l.log.warn("Failed to new Worker",b),null}}).filter(Boolean),fontFamilies:new Map,fontCssTexts:new Map,acceptOfImage:`${[ut(o)&&"image/webp","image/svg+xml","image/*","*/*"].filter(Boolean).join(",")};q=0.8`,requests:u,drawImageCount:0,tasks:[],features:s,isEnable:b=>{var w,S;return b==="restoreScrollPosition"?typeof s=="boolean"?!1:(w=s[b])!=null?w:!1:typeof s=="boolean"?s:(S=s[b])!=null?S:!0},shadowRoots:[]});l.log.time("wait until load"),yield me(e,{timeout:l.timeout,onWarn:l.log.warn}),l.log.timeEnd("wait until load");const{width:d,height:m}=pt(e,l);return l.width=d,l.height=m,l})}function be(e){if(!e)return;const t=e.createElement("style"),r=t.ownerDocument.createTextNode(` +.______background-clip--text { + background-clip: text; + -webkit-background-clip: text; +} +`);return t.appendChild(r),t}function pt(e,t){let{width:r,height:n}=t;if(I(e)&&(!r||!n)){const i=e.getBoundingClientRect();r=r||i.width||Number(e.getAttribute("width"))||0,n=n||i.height||Number(e.getAttribute("height"))||0}return{width:r,height:n}}function yt(e,t){return C(this,null,function*(){const{log:r,timeout:n,drawImageCount:i,drawImageInterval:a}=t;r.time("image to canvas");const s=yield L(e,{timeout:n,onWarn:t.log.warn}),{canvas:o,context2d:c}=bt(e.ownerDocument,t),u=()=>{try{c==null||c.drawImage(s,0,0,o.width,o.height)}catch(l){t.log.warn("Failed to drawImage",l)}};if(u(),t.isEnable("fixSvgXmlDecode"))for(let l=0;l{setTimeout(()=>{c==null||c.clearRect(0,0,o.width,o.height),u(),d()},l+a)});return t.drawImageCount=0,r.timeEnd("image to canvas"),o})}function bt(e,t){const{width:r,height:n,scale:i,backgroundColor:a,maximumCanvasSize:s}=t,o=e.createElement("canvas");o.width=Math.floor(r*i),o.height=Math.floor(n*i),o.style.width=`${r}px`,o.style.height=`${n}px`,s&&(o.width>s||o.height>s)&&(o.width>s&&o.height>s?o.width>o.height?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s):o.width>s?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s));const c=o.getContext("2d");return c&&a&&(c.fillStyle=a,c.fillRect(0,0,o.width,o.height)),{canvas:o,context2d:c}}function Se(e,t){if(e.ownerDocument)try{const a=e.toDataURL();if(a!=="data:,")return _(a,e.ownerDocument)}catch(a){t.log.warn("Failed to clone canvas",a)}const r=e.cloneNode(!1),n=e.getContext("2d"),i=r.getContext("2d");try{return n&&i&&i.putImageData(n.getImageData(0,0,e.width,e.height),0,0),r}catch(a){t.log.warn("Failed to clone canvas",a)}return r}function St(e,t){var r;try{if((r=e==null?void 0:e.contentDocument)!=null&&r.documentElement)return ne(e.contentDocument.documentElement,t)}catch(n){t.log.warn("Failed to clone iframe",n)}return e.cloneNode(!1)}function Et(e){const t=e.cloneNode(!1);return e.currentSrc&&e.currentSrc!==e.src&&(t.src=e.currentSrc,t.srcset=""),t.loading==="lazy"&&(t.loading="eager"),t}function Ct(e,t){return C(this,null,function*(){if(e.ownerDocument&&!e.currentSrc&&e.poster)return _(e.poster,e.ownerDocument);const r=e.cloneNode(!1);r.crossOrigin="anonymous",e.currentSrc&&e.currentSrc!==e.src&&(r.src=e.currentSrc);const n=r.ownerDocument;if(n){let i=!0;if(yield L(r,{onError:()=>i=!1,onWarn:t.log.warn}),!i)return e.poster?_(e.poster,e.ownerDocument):r;r.currentTime=e.currentTime,yield new Promise(s=>{r.addEventListener("seeked",s,{once:!0})});const a=n.createElement("canvas");a.width=e.offsetWidth,a.height=e.offsetHeight;try{const s=a.getContext("2d");s&&s.drawImage(r,0,0,a.width,a.height)}catch(s){return t.log.warn("Failed to clone video",s),e.poster?_(e.poster,e.ownerDocument):r}return Se(a,t)}return r})}function Tt(e,t){return tt(e)?Se(e,t):lt(e)?St(e,t):$(e)?Et(e):Y(e)?Ct(e,t):e.cloneNode(!1)}function vt(e){let t=e.sandbox;if(!t){const{ownerDocument:r}=e;try{r&&(t=r.createElement("iframe"),t.id=`__SANDBOX__${he()}`,t.width="0",t.height="0",t.style.visibility="hidden",t.style.position="fixed",r.body.appendChild(t),t.srcdoc='',e.sandbox=t)}catch(n){e.log.warn("Failed to getSandBox",n)}}return t}const At=["width","height","-webkit-text-fill-color"],Nt=["stroke","fill"];function Ee(e,t,r){const{defaultComputedStyles:n}=r,i=e.nodeName.toLowerCase(),a=H(e)&&i!=="svg",s=a?Nt.map(g=>[g,e.getAttribute(g)]).filter(([,g])=>g!==null):[],o=[a&&"svg",i,s.map((g,p)=>`${g}=${p}`).join(","),t].filter(Boolean).join(":");if(n.has(o))return n.get(o);const c=vt(r),u=c==null?void 0:c.contentWindow;if(!u)return new Map;const l=u==null?void 0:u.document;let d,m;a?(d=l.createElementNS(K,"svg"),m=d.ownerDocument.createElementNS(d.namespaceURI,i),s.forEach(([g,p])=>{m.setAttributeNS(null,g,p)}),d.appendChild(m)):d=m=l.createElement(i),m.textContent=" ",l.body.appendChild(d);const f=u.getComputedStyle(m,t),h=new Map;for(let g=f.length,p=0;pn.set(d,l));function s(c){const u=e.getPropertyValue(c),l=e.getPropertyPriority(c),d=c.lastIndexOf("-"),m=d>-1?c.substring(0,d):void 0;if(m){let f=a.get(m);f||(f=new Map,a.set(m,f)),f.set(c,[u,l])}t.get(c)===u&&!l||(m?i.push(m):n.set(c,[u,l]))}return n}function Rt(e,t,r,n){var d,m,f,h;const{ownerWindow:i,includeStyleProperties:a,currentParentNodeStyle:s}=n,o=t.style,c=i.getComputedStyle(e),u=Ee(e,null,n);s==null||s.forEach((g,p)=>{u.delete(p)});const l=Ce(c,u,a);l.delete("transition-property"),l.delete("all"),l.delete("d"),l.delete("content"),r&&(l.delete("position"),l.delete("margin-top"),l.delete("margin-right"),l.delete("margin-bottom"),l.delete("margin-left"),l.delete("margin-block-start"),l.delete("margin-block-end"),l.delete("margin-inline-start"),l.delete("margin-inline-end"),l.set("box-sizing",["border-box",""])),((d=l.get("background-clip"))==null?void 0:d[0])==="text"&&t.classList.add("______background-clip--text"),le&&(l.has("font-kerning")||l.set("font-kerning",["normal",""]),(((m=l.get("overflow-x"))==null?void 0:m[0])==="hidden"||((f=l.get("overflow-y"))==null?void 0:f[0])==="hidden")&&((h=l.get("text-overflow"))==null?void 0:h[0])==="ellipsis"&&e.scrollWidth===e.clientWidth&&l.set("text-overflow",["clip",""]));for(let g=o.length,p=0;p{o.setProperty(E,g,p)}),l}function It(e,t){(rt(e)||nt(e)||st(e))&&t.setAttribute("value",e.value)}const kt=["::before","::after"],Dt=["::-webkit-scrollbar","::-webkit-scrollbar-button","::-webkit-scrollbar-thumb","::-webkit-scrollbar-track","::-webkit-scrollbar-track-piece","::-webkit-scrollbar-corner","::-webkit-resizer"];function Pt(e,t,r,n,i){const{ownerWindow:a,svgStyleElement:s,svgStyles:o,currentNodeStyle:c}=n;if(!s||!a)return;function u(l){var w;const d=a.getComputedStyle(e,l);let m=d.getPropertyValue("content");if(!m||m==="none")return;i==null||i(m),m=m.replace(/(')|(")|(counter\(.+\))/g,"");const f=[he()],h=Ee(e,l,n);c==null||c.forEach((S,T)=>{h.delete(T)});const g=Ce(d,h,n.includeStyleProperties);g.delete("content"),g.delete("-webkit-locale"),((w=g.get("background-clip"))==null?void 0:w[0])==="text"&&t.classList.add("______background-clip--text");const p=[`content: '${m}';`];if(g.forEach(([S,T],A)=>{p.push(`${A}: ${S}${T?" !important":""};`)}),p.length===1)return;try{t.className=[t.className,...f].join(" ")}catch(S){n.log.warn("Failed to copyPseudoClass",S);return}const E=p.join(` + `);let b=o.get(E);b||(b=[],o.set(E,b)),b.push(`.${f[0]}${l}`)}kt.forEach(u),r&&Dt.forEach(u)}const Te=new Set(["symbol"]);function ve(e,t,r,n,i){return C(this,null,function*(){if(I(r)&&(ot(r)||at(r))||n.filter&&!n.filter(r))return;Te.has(t.nodeName)||Te.has(r.nodeName)?n.currentParentNodeStyle=void 0:n.currentParentNodeStyle=n.currentNodeStyle;const a=yield ne(r,n,!1,i);n.isEnable("restoreScrollPosition")&&Ut(e,a),t.appendChild(a)})}function Ae(e,t,r,n){return C(this,null,function*(){var a;let i=e.firstChild;I(e)&&e.shadowRoot&&(i=(a=e.shadowRoot)==null?void 0:a.firstChild,r.shadowRoots.push(e.shadowRoot));for(let s=i;s;s=s.nextSibling)if(!Ze(s))if(I(s)&&it(s)&&typeof s.assignedNodes=="function"){const o=s.assignedNodes();for(let c=0;ce.clientHeight||e.scrollWidth>e.clientWidth)}const p=(d=h.get("text-transform"))==null?void 0:d[0],E=we((m=h.get("font-family"))==null?void 0:m[0]),b=E?w=>{p==="uppercase"?w=w.toUpperCase():p==="lowercase"?w=w.toLowerCase():p==="capitalize"&&(w=w[0].toUpperCase()+w.substring(1)),E.forEach(S=>{let T=s.get(S);T||s.set(S,T=new Set),w.split("").forEach(A=>T.add(A))})}:void 0;return Pt(e,f,g,t,b),It(e,f),Y(e)||(yield Ae(e,f,t,b)),yield o==null?void 0:o(f),f}const c=e.cloneNode(!1);return yield Ae(e,c,t),yield o==null?void 0:o(c),c})}function Ne(e){if(e.ownerDocument=void 0,e.ownerWindow=void 0,e.svgStyleElement=void 0,e.svgDefsElement=void 0,e.svgStyles.clear(),e.defaultComputedStyles.clear(),e.sandbox){try{e.sandbox.remove()}catch(t){e.log.warn("Failed to destroyContext",t)}e.sandbox=void 0}e.workers=[],e.fontFamilies.clear(),e.fontCssTexts.clear(),e.requests.clear(),e.tasks=[],e.shadowRoots=[]}function Bt(e){const o=e,{url:t,timeout:r,responseType:n}=o,i=je(o,["url","timeout","responseType"]),a=new AbortController,s=r?setTimeout(()=>a.abort(),r):void 0;return fetch(t,D({signal:a.signal},i)).then(c=>{if(!c.ok)throw new Error("Failed fetch, not 2xx response",{cause:c});switch(n){case"arrayBuffer":return c.arrayBuffer();case"dataUrl":return c.blob().then(gt);case"text":default:return c.text()}}).finally(()=>clearTimeout(s))}function z(e,t){const{url:r,requestType:n="text",responseType:i="text",imageDom:a}=t;let s=r;const{timeout:o,acceptOfImage:c,requests:u,fetchFn:l,fetch:{requestInit:d,bypassingCache:m,placeholderImage:f},font:h,workers:g,fontFamilies:p}=e;n==="image"&&(G||te)&&e.drawImageCount++;let E=u.get(r);if(!E){m&&m instanceof RegExp&&m.test(s)&&(s+=(/\?/.test(s)?"&":"?")+new Date().getTime());const b=n.startsWith("font")&&h&&h.minify,w=new Set;b&&n.split(";")[1].split(",").forEach(F=>{p.has(F)&&p.get(F).forEach(B=>w.add(B))});const S=b&&w.size,T=D({url:s,timeout:o,responseType:S?"arrayBuffer":i,headers:n==="image"?{accept:c}:void 0},d);E={type:n,resolve:void 0,reject:void 0,response:null},E.response=C(this,null,function*(){if(l&&n==="image"){const A=yield l(r);if(A)return A}return!G&&r.startsWith("http")&&g.length?new Promise((A,F)=>{g[u.size&g.length-1].postMessage(D({rawUrl:r},T)),E.resolve=A,E.reject=F}):Bt(T)}).catch(A=>{if(u.delete(r),n==="image"&&f)return e.log.warn("Failed to fetch image base64, trying to use placeholder image",s),typeof f=="string"?f:f(a);throw A}),u.set(r,E)}return E.response}function Re(e,t,r,n){return C(this,null,function*(){if(!Ie(e))return e;for(const[i,a]of $t(e,t))try{const s=yield z(r,{url:a,requestType:n?"image":"text",responseType:"dataUrl"});e=e.replace(Lt(i),`$1${s}$3`)}catch(s){r.log.warn("Failed to fetch css data url",i,s)}return e})}function Ie(e){return/url\((['"]?)([^'"]+?)\1\)/.test(e)}const ke=/url\((['"]?)([^'"]+?)\1\)/g;function $t(e,t){const r=[];return e.replace(ke,(n,i,a)=>(r.push([a,ue(a,t)]),n)),r.filter(([n])=>!re(n))}function Lt(e){const t=e.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${t})(['"]?\\))`,"g")}const xt=["background-image","border-image-source","-webkit-border-image","-webkit-mask-image","list-style-image"];function Mt(e,t){return xt.map(r=>{const n=e.getPropertyValue(r);return!n||n==="none"?null:((G||te)&&t.drawImageCount++,Re(n,null,t,!0).then(i=>{!i||n===i||e.setProperty(r,i,e.getPropertyPriority(r))}))}).filter(Boolean)}function Ot(e,t){if($(e)){const r=e.currentSrc||e.src;if(!re(r))return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.srcset="",e.dataset.originalSrc=r,e.src=n||"")})];(G||te)&&t.drawImageCount++}else if(H(e)&&!re(e.href.baseVal)){const r=e.href.baseVal;return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.dataset.originalSrc=r,e.href.baseVal=n||"")})]}return[]}function jt(e,t){var o;const{ownerDocument:r,svgDefsElement:n}=t,i=(o=e.getAttribute("href"))!=null?o:e.getAttribute("xlink:href");if(!i)return[];const[a,s]=i.split("#");if(s){const c=`#${s}`,u=t.shadowRoots.reduce((l,d)=>l!=null?l:d.querySelector(`svg ${c}`),r==null?void 0:r.querySelector(`svg ${c}`));if(a&&e.setAttribute("href",c),n!=null&&n.querySelector(c))return[];if(u)return n==null||n.appendChild(u.cloneNode(!0)),[];if(a)return[z(t,{url:a,responseType:"text"}).then(l=>{n==null||n.insertAdjacentHTML("beforeend",l)})]}return[]}function De(e,t){const{tasks:r}=t;I(e)&&(($(e)||ce(e))&&r.push(...Ot(e,t)),Qe(e)&&r.push(...jt(e,t))),V(e)&&r.push(...Mt(e.style,t)),e.childNodes.forEach(n=>{De(n,t)})}function qt(e,t){return C(this,null,function*(){const{ownerDocument:r,svgStyleElement:n,fontFamilies:i,fontCssTexts:a,tasks:s,font:o}=t;if(!(!r||!n||!i.size))if(o&&o.cssText){const c=Ue(o.cssText,t);n.appendChild(r.createTextNode(`${c} +`))}else{const c=Array.from(r.styleSheets).filter(f=>{try{return"cssRules"in f&&!!f.cssRules.length}catch(h){return t.log.warn(`Error while reading CSS rules from ${f.href}`,h),!1}}),u=r.implementation.createHTMLDocument(""),l=u.createElement("style");u.head.appendChild(l);const d=l.sheet;yield Promise.all(c.flatMap(f=>Array.from(f.cssRules).map(h=>C(this,null,function*(){if(Je(h)){const g=h.href;let p="";try{p=yield z(t,{url:g,requestType:"text",responseType:"text"})}catch(b){t.log.warn(`Error fetch remote css import from ${g}`,b)}const E=p.replace(ke,(b,w,S)=>b.replace(S,ue(S,g)));for(const b of Ht(E))try{d.insertRule(b,d.cssRules.length)}catch(w){t.log.warn("Error inserting rule from remote css import",{rule:b,error:w})}}})))),d.cssRules.length&&c.push(d);const m=[];c.forEach(f=>{oe(f.cssRules,m)}),m.filter(f=>{var h;return Ye(f)&&Ie(f.style.getPropertyValue("src"))&&((h=we(f.style.getPropertyValue("font-family")))==null?void 0:h.some(g=>i.has(g)))}).forEach(f=>{const h=f,g=a.get(h.cssText);g?n.appendChild(r.createTextNode(`${g} +`)):s.push(Re(h.cssText,h.parentStyleSheet?h.parentStyleSheet.href:null,t).then(p=>{p=Ue(p,t),a.set(h.cssText,p),n.appendChild(r.createTextNode(`${p} +`))}))})}})}const Wt=/(\/\*[\s\S]*?\*\/)/g,Pe=/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi;function Ht(e){if(e==null)return[];const t=[];let r=e.replace(Wt,"");for(;;){const a=Pe.exec(r);if(!a)break;t.push(a[0])}r=r.replace(Pe,"");const n=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,i=new RegExp("((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})","gi");for(;;){let a=n.exec(r);if(a)i.lastIndex=n.lastIndex;else if(a=i.exec(r),a)n.lastIndex=i.lastIndex;else break;t.push(a[0])}return t}const Vt=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,zt=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Ue(e,t){const{font:r}=t,n=r?r==null?void 0:r.preferredFormat:void 0;return n?e.replace(zt,i=>{for(;;){const[a,,s]=Vt.exec(i)||[];if(!s)return"";if(s===n)return`src: ${a};`}}):e}function oe(e,t=[]){for(const r of Array.from(e))Ke(r)?t.push(...oe(r.cssRules)):"cssRules"in r?oe(r.cssRules,t):t.push(r);return t}const Xt=/\bx?link:?href\s*=\s*["'](?!data:)[^"']+["']/i;function Gt(e){return Xt.test(e.innerHTML)}function _e(e,t){return C(this,null,function*(){const r=yield k(e,t);if(I(r.node)&&H(r.node)&&!Gt(r.node))return r.node;const{ownerDocument:n,log:i,tasks:a,svgStyleElement:s,svgDefsElement:o,svgStyles:c,font:u,progress:l,autoDestruct:d,onCloneNode:m,onEmbedNode:f,onCreateForeignObjectSvg:h}=r;i.time("clone node");const g=yield ne(r.node,r,!0);if(s&&n){let S="";c.forEach((T,A)=>{S+=`${T.join(`, +`)} { + ${A} +} +`}),s.appendChild(n.createTextNode(S))}i.timeEnd("clone node"),yield m==null?void 0:m(g),u!==!1&&I(g)&&(i.time("embed web font"),yield qt(g,r),i.timeEnd("embed web font")),i.time("embed node"),De(g,r);const p=a.length;let E=0;const b=()=>C(this,null,function*(){for(;;){const S=a.pop();if(!S)break;try{yield S}catch(T){r.log.warn("Failed to run task",T)}l==null||l(++E,p)}});l==null||l(E,p),yield Promise.all([...Array.from({length:4})].map(b)),i.timeEnd("embed node"),yield f==null?void 0:f(g);const w=Yt(g,r);return o&&w.insertBefore(o,w.children[0]),s&&w.insertBefore(s,w.children[0]),d&&Ne(r),yield h==null?void 0:h(w),w})}function Yt(e,t){const{width:r,height:n}=t,i=fe(r,n,e.ownerDocument),a=i.ownerDocument.createElementNS(i.namespaceURI,"foreignObject");return a.setAttributeNS(null,"x","0%"),a.setAttributeNS(null,"y","0%"),a.setAttributeNS(null,"width","100%"),a.setAttributeNS(null,"height","100%"),a.append(e),i.appendChild(a),i}function Q(e,t){return C(this,null,function*(){var s;const r=yield k(e,t),n=yield _e(r),i=de(n,r.isEnable("removeControlCharacter"));r.autoDestruct||(r.svgStyleElement=be(r.ownerDocument),r.svgDefsElement=(s=r.ownerDocument)==null?void 0:s.createElementNS(K,"defs"),r.svgStyles.clear());const a=_(i,n.ownerDocument);return yield yt(a,r)})}function Jt(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,type:i,quality:a,dpi:s}=r,o=yield Q(r);n.time("canvas to blob");const c=yield ft(o,i,a);if(["image/png","image/jpeg"].includes(i)&&s){const u=yield mt(c.slice(0,33));let l=new Uint8Array(u);return i==="image/png"?l=ae(l,s):i==="image/jpeg"&&(l=v(l,s)),n.timeEnd("canvas to blob"),new Blob([l,c.slice(33)],{type:i})}return n.timeEnd("canvas to blob"),c})}function x(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,quality:i,type:a,dpi:s}=r,o=yield Q(r);n.time("canvas to data url");let c=o.toDataURL(a,i);if(["image/png","image/jpeg"].includes(a)&&s&&ie&&Xe){const[u,l]=c.split(",");let d=0,m=!1;if(a==="image/png"){const w=Ve(l);w>=0?(d=Math.ceil((w+28)/3)*4,m=!0):d=33/3*4}else a==="image/jpeg"&&(d=18/3*4);const f=l.substring(0,d),h=l.substring(d),g=window.atob(f),p=new Uint8Array(g.length);for(let w=0;w mirroring selectedElement's bounding rect. Click (no + // drag) drops a comment pin; drag paints a magenta SVG stroke. All coords + // are stored in element-local CSS px so they survive scroll / resize and + // correlate directly with the captured PNG. + // --------------------------------------------------------------------------- + + const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click + const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it + let annotOverlayEl = null; + let annotSvgEl = null; + let annotPinsEl = null; + let annotClearChipEl = null; + let annotState = { comments: [], strokes: [] }; + let annotActive = false; + // `annotPointer` is either: + // { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin + // { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin + let annotPointer = null; + let annotEditing = null; // { idx, input, wrapEl } + let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete + + function initAnnotOverlay() { + annotOverlayEl = document.createElement('div'); + annotOverlayEl.id = PREFIX + '-annot'; + Object.assign(annotOverlayEl.style, { + position: 'fixed', top: '0', left: '0', width: '0', height: '0', + pointerEvents: 'auto', zIndex: Z.highlight + 2, + display: 'none', overflow: 'visible', + cursor: 'crosshair', touchAction: 'none', + }); + + annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + annotSvgEl.id = PREFIX + '-annot-svg'; + Object.assign(annotSvgEl.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', + // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via + // pointer-events=stroke so gaps still fall through to the overlay. + pointerEvents: 'none', overflow: 'visible', + }); + annotOverlayEl.appendChild(annotSvgEl); + + annotPinsEl = document.createElement('div'); + annotPinsEl.id = PREFIX + '-annot-pins'; + Object.assign(annotPinsEl.style, { + position: 'absolute', inset: '0', + pointerEvents: 'none', + }); + annotOverlayEl.appendChild(annotPinsEl); + + annotClearChipEl = document.createElement('div'); + annotClearChipEl.id = PREFIX + '-annot-clear'; + annotClearChipEl.dataset.annotClear = 'true'; + annotClearChipEl.textContent = 'Clear'; + Object.assign(annotClearChipEl.style, { + position: 'absolute', top: '8px', right: '8px', + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '10px', fontWeight: '500', + letterSpacing: '0.08em', textTransform: 'uppercase', + padding: '5px 12px', borderRadius: '999px', + cursor: 'pointer', pointerEvents: 'auto', + display: 'none', userSelect: 'none', + boxShadow: '0 1px 3px rgba(0,0,0,0.2)', + }); + annotOverlayEl.appendChild(annotClearChipEl); + + annotOverlayEl.addEventListener('pointerdown', onAnnotDown); + annotOverlayEl.addEventListener('pointermove', onAnnotMove); + annotOverlayEl.addEventListener('pointerup', onAnnotUp); + annotOverlayEl.addEventListener('pointercancel', onAnnotUp); + document.body.appendChild(annotOverlayEl); + } + + function updateClearChip() { + if (!annotClearChipEl) return; + const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0; + annotClearChipEl.style.display = hasAny ? 'block' : 'none'; + } + + function showAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + annotActive = true; + positionAnnotOverlay(el); + annotOverlayEl.style.display = 'block'; + } + + function hideAnnotOverlay() { + annotActive = false; + if (annotOverlayEl) annotOverlayEl.style.display = 'none'; + // Drop any in-progress edit without touching annotState — clearAnnotations + // (if the caller is exiting configure mode) handles state reset. + annotEditing = null; + } + + function positionAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + const r = el.getBoundingClientRect(); + Object.assign(annotOverlayEl.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height); + } + + function clearAnnotations() { + annotState.comments = []; + annotState.strokes = []; + if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + if (annotPinsEl) annotPinsEl.innerHTML = ''; + annotPointer = null; + annotEditing = null; + annotLastPinClick = { idx: -1, time: 0 }; + updateClearChip(); + } + + // Rebuild the SVG layer. Each stroke gets a wider invisible hit path + // beneath the visible magenta path so clicks register on thin lines. + function redrawStrokes() { + while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + annotState.strokes.forEach((s, idx) => { + const d = pointsToPath(s.points); + const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + hit.setAttribute('d', d); + hit.setAttribute('stroke', 'transparent'); + hit.setAttribute('stroke-width', '16'); + hit.setAttribute('stroke-linecap', 'round'); + hit.setAttribute('stroke-linejoin', 'round'); + hit.setAttribute('fill', 'none'); + hit.setAttribute('pointer-events', 'stroke'); + hit.style.cursor = 'pointer'; + hit.dataset.annotStroke = String(idx); + annotSvgEl.appendChild(hit); + const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + visible.setAttribute('d', d); + visible.setAttribute('stroke', C.brand); + visible.setAttribute('stroke-width', '3'); + visible.setAttribute('stroke-linecap', 'round'); + visible.setAttribute('stroke-linejoin', 'round'); + visible.setAttribute('fill', 'none'); + visible.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(visible); + }); + updateClearChip(); + } + + function localCoords(e) { + const rect = annotOverlayEl.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + function onAnnotDown(e) { + if (!annotActive) return; + + // 1) Clear chip → wipe all annotations + if (e.target.closest?.('[data-annot-clear]')) { + if (annotEditing) annotEditing = null; + clearAnnotations(); + renderAllPins(); + redrawStrokes(); + e.stopPropagation(); e.preventDefault(); + return; + } + + // 2) Stroke hit path → delete that stroke + const strokeHit = e.target.closest?.('[data-annot-stroke]'); + if (strokeHit) { + const idx = parseInt(strokeHit.dataset.annotStroke, 10); + if (Number.isInteger(idx)) { + annotState.strokes.splice(idx, 1); + redrawStrokes(); + } + e.stopPropagation(); e.preventDefault(); + return; + } + + // 3) Pin → drag, edit, or delete-on-double-click + const pinWrap = e.target.closest?.('[data-annot-pin]'); + if (pinWrap) { + const idx = parseInt(pinWrap.dataset.annotPin, 10); + if (!Number.isInteger(idx)) return; + // Double-click (two pointerdowns on the same pin within window) → delete. + const now = Date.now(); + if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) { + if (annotEditing && annotEditing.idx === idx) annotEditing = null; + annotState.comments.splice(idx, 1); + annotLastPinClick = { idx: -1, time: 0 }; + renderAllPins(); + e.stopPropagation(); e.preventDefault(); + return; + } + annotLastPinClick = { idx, time: now }; + // If editing a different pin, commit that edit before starting here. + if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin(); + // If already editing THIS pin and the user clicked the dot, let the + // input keep focus (don't start a drag — the click wasn't meant as one). + if (annotEditing && annotEditing.idx === idx) return; + const p = localCoords(e); + const pin = annotState.comments[idx]; + annotPointer = { + kind: 'pin', idx, + startPointer: p, + startPin: { x: pin.x, y: pin.y }, + moved: false, + }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + return; + } + + // 4) Empty area → commit any open edit, then start new annotation + if (annotEditing) { + finalizeEditingPin(); + e.stopPropagation(); e.preventDefault(); + return; + } + const p = localCoords(e); + annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + } + + function onAnnotMove(e) { + if (!annotActive || !annotPointer) return; + const p = localCoords(e); + + if (annotPointer.kind === 'pin') { + const dx = p.x - annotPointer.startPointer.x; + const dy = p.y - annotPointer.startPointer.y; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + } + const pin = annotState.comments[annotPointer.idx]; + if (!pin) { annotPointer = null; return; } + pin.x = annotPointer.startPin.x + dx; + pin.y = annotPointer.startPin.y + dy; + renderAllPins(); + e.stopPropagation(); + return; + } + + // kind === 'new' + const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + strokeEl.setAttribute('stroke', C.brand); + strokeEl.setAttribute('stroke-width', '3'); + strokeEl.setAttribute('stroke-linecap', 'round'); + strokeEl.setAttribute('stroke-linejoin', 'round'); + strokeEl.setAttribute('fill', 'none'); + strokeEl.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(strokeEl); + annotPointer.strokeEl = strokeEl; + annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]]; + } + annotPointer.strokePoints.push([p.x, p.y]); + annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints)); + e.stopPropagation(); + } + + function onAnnotUp(e) { + if (!annotActive || !annotPointer) return; + + if (annotPointer.kind === 'pin') { + const wasDrag = annotPointer.moved; + const idx = annotPointer.idx; + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + if (wasDrag) { + // A drag is an intentional reposition; a follow-up click shouldn't be + // interpreted as a double-click-to-delete. + annotLastPinClick = { idx: -1, time: 0 }; + } else { + beginEditPin(idx); + } + e.stopPropagation(); + return; + } + + // kind === 'new' + const wasDrag = annotPointer.moved; + if (wasDrag) { + annotState.strokes.push({ points: annotPointer.strokePoints }); + // Swap the temporary preview SVG path for the full render with hit paths. + redrawStrokes(); + } else { + const idx = annotState.comments.length; + annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' }); + renderAllPins(); + beginEditPin(idx); + } + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + e.stopPropagation(); + } + + function pointsToPath(points) { + if (!points || points.length === 0) return ''; + let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1); + for (let i = 1; i < points.length; i++) { + d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1); + } + return d; + } + + function renderAllPins() { + annotPinsEl.innerHTML = ''; + annotState.comments.forEach((c, idx) => { + annotPinsEl.appendChild(buildPinElement(c, idx)); + }); + updateClearChip(); + } + + function buildPinElement(comment, idx) { + const interactive = idx >= 0; + const wrap = document.createElement('div'); + if (interactive) wrap.dataset.annotPin = String(idx); + Object.assign(wrap.style, { + position: 'absolute', + left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px', + pointerEvents: interactive ? 'auto' : 'none', + display: 'flex', alignItems: 'flex-start', gap: '6px', + cursor: interactive ? 'grab' : 'default', + touchAction: 'none', + }); + const dot = document.createElement('div'); + Object.assign(dot.style, { + width: '14px', height: '14px', borderRadius: '50%', + background: C.brand, border: '2px solid ' + C.white, + boxShadow: '0 1px 3px rgba(0,0,0,0.25)', + flexShrink: '0', + }); + wrap.appendChild(dot); + + if (comment.text) { + const bubble = document.createElement('div'); + bubble.textContent = comment.text; + Object.assign(bubble.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + marginTop: '-2px', maxWidth: '220px', + pointerEvents: 'none', whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }); + wrap.appendChild(bubble); + } + return wrap; + } + + function beginEditPin(idx) { + const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]'); + if (!wrapEl) return; + // Strip any existing bubble (but keep the dot) + wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove()); + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Note…'; + Object.assign(input.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + border: '1px solid ' + C.brand, + outline: 'none', marginTop: '-2px', + width: '220px', pointerEvents: 'auto', + }); + const originalText = annotState.comments[idx].text || ''; + input.value = originalText; + wrapEl.appendChild(input); + annotEditing = { idx, input, wrapEl, originalText }; + input.addEventListener('keydown', onAnnotInputKey, true); + input.addEventListener('blur', () => { + // Fires on both focus-loss and programmatic blur; commit unless we + // already handled it. + if (annotEditing && annotEditing.input === input) finalizeEditingPin(); + }); + // Stop clicks/pointerdowns inside the input from bubbling to the overlay + ['pointerdown', 'click'].forEach(ev => { + input.addEventListener(ev, e => e.stopPropagation()); + }); + setTimeout(() => input.focus(), 0); + } + + function onAnnotInputKey(e) { + if (e.key === 'Enter') { + e.preventDefault(); e.stopPropagation(); + finalizeEditingPin(); + } else if (e.key === 'Escape') { + e.preventDefault(); e.stopPropagation(); + cancelEditingPin(); + } else { + // Keep arrows / backspace from hitting global handlers + e.stopPropagation(); + } + } + + function finalizeEditingPin() { + if (!annotEditing) return; + const { idx, input } = annotEditing; + const text = input.value.trim(); + annotEditing = null; + if (text) annotState.comments[idx].text = text; + else annotState.comments.splice(idx, 1); + renderAllPins(); + } + + function cancelEditingPin() { + if (!annotEditing) return; + const { idx, originalText } = annotEditing; + annotEditing = null; + // If the pin had text before this edit, revert to it. If it was a + // just-created empty pin, Escape removes it. + if (originalText) { + annotState.comments[idx].text = originalText; + } else { + annotState.comments.splice(idx, 1); + } + renderAllPins(); + } + + // Build a detached annotation subtree suitable for injection into the clone + // modern-screenshot creates. Coordinates are element-local so this slots + // straight into an element that's been made position:relative. Takes an + // explicit snapshot so it works after annotState has been cleared. + function buildAnnotationsForCapture(rect, snapshot) { + const comments = snapshot ? snapshot.comments : annotState.comments; + const strokes = snapshot ? snapshot.strokes : annotState.strokes; + if (comments.length === 0 && strokes.length === 0) return null; + const wrap = document.createElement('div'); + Object.assign(wrap.style, { + position: 'absolute', top: '0', left: '0', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', overflow: 'visible', + }); + if (strokes.length > 0) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height); + Object.assign(svg.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', overflow: 'visible', + }); + for (const s of strokes) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('stroke', C.brand); + path.setAttribute('stroke-width', '3'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('fill', 'none'); + path.setAttribute('d', pointsToPath(s.points)); + svg.appendChild(path); + } + wrap.appendChild(svg); + } + for (const c of comments) { + // idx=-1 means non-interactive; pointerEvents stay off in the clone + wrap.appendChild(buildPinElement(c, -1)); + } + return wrap; } // --------------------------------------------------------------------------- @@ -416,8 +901,10 @@ fontSize: '11px', color: C.ash, whiteSpace: 'nowrap', marginLeft: 'auto', }); + // Variants currently arrive atomically in a single file edit, so a + // per-variant counter would lie. Say what's true. status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + (arrivedVariants + 1) + ' of ' + expectedVariants + '...' + ? 'Generating ' + expectedVariants + ' variants...' : 'Done'; row.appendChild(status); @@ -838,6 +1325,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + hideShaderOverlay(); updateBarContent('cycling'); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -860,6 +1348,10 @@ positionBar(); showHighlight(selectedElement); } + if (annotActive) positionAnnotOverlay(selectedElement); + // Shader overlay (via debug P toggle or generation) is repositioned + // by its own branch below; debug no longer has a separate overlay. + if (shaderState) positionShaderOverlay(); scrollRaf = requestAnimationFrame(tick); } scrollRaf = requestAnimationFrame(tick); @@ -937,6 +1429,8 @@ } hideBar(); hideHighlight(); + hideShaderOverlay(); + hideAnnotOverlay(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } clearSession(); @@ -976,6 +1470,8 @@ if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { hideBar(); stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -989,15 +1485,19 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); } function handleKeyDown(e) { + // When the annotation input is focused, let it handle its own keys. + if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); state = 'PICKING'; return; } + if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { hideHighlight(); state = 'IDLE'; return; } @@ -1024,6 +1524,8 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); return; @@ -1035,6 +1537,8 @@ } else { // CONFIGURING: re-select the new element and refresh the bar selectedElement = next; + clearAnnotations(); + showAnnotOverlay(next); showBar('configure'); startScrollTracking(); } @@ -1056,25 +1560,408 @@ const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; + // Commit any pending pin edit BEFORE we snapshot annotations. + if (annotEditing) finalizeEditingPin(); + currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; - sendEvent({ + // Flip to GENERATING immediately so the bar morphs without waiting on + // capture + upload. The event is emitted from captureAndEmit() once the + // screenshot is uploaded (or capture fails — we still emit, just without + // screenshotPath). + const elForCapture = selectedElement; + const captureRect = elForCapture.getBoundingClientRect(); + const snapshot = { + comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), + strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })), + }; + const basePayload = { type: 'generate', id: currentSessionId, action: selectedAction, freeformPrompt: prompt || undefined, count: selectedCount, pageUrl: location.pathname, - element: extractContext(selectedElement), - }); + element: extractContext(elForCapture), + }; + if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments; + if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes; + + // Hide the interactive overlay so it doesn't linger during generation. + hideAnnotOverlay(); + clearAnnotations(); state = 'GENERATING'; showBar('generating'); saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + + captureAndEmit(elForCapture, basePayload, snapshot, captureRect); + } + + // --------------------------------------------------------------------------- + // Screenshot capture + upload + // --------------------------------------------------------------------------- + + let msLoadPromise = null; + function loadModernScreenshot() { + if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot); + if (msLoadPromise) return msLoadPromise; + msLoadPromise = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; + s.onload = () => resolve(window.modernScreenshot); + s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; + document.head.appendChild(s); + }); + return msLoadPromise; + } + + // Collect @font-face rules from every stylesheet on the page. Cross-origin + // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules + // access, so modern-screenshot can't embed them on its own — the resulting + // SVG falls back to system fonts and text re-wraps + renders with different + // weight. We fetch the raw CSS text (CORS-permitted for these providers), + // extract @font-face blocks, inline the referenced font files as base64 + // data URIs (SVGs rasterized via canvas can't fetch external resources, + // so URLs inside the SVG silently fail without this), and pass the result + // to modern-screenshot as font.cssText. + const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i; + const FONT_MIME = { + woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject', + }; + function bufferToBase64(buf) { + const bytes = new Uint8Array(buf); + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); + } + async function inlineFontUrls(cssText) { + const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g; + const urls = new Set(); + let m; + while ((m = urlRe.exec(cssText))) { + if (FONT_EXT_RE.test(m[2])) urls.add(m[2]); + } + const map = new Map(); + await Promise.all([...urls].map(async (url) => { + try { + const res = await fetch(url); + if (!res.ok) return; + const buf = await res.arrayBuffer(); + const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2'; + const mime = FONT_MIME[ext] || 'application/octet-stream'; + map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf)); + } catch { /* skip; fall through to URL */ } + })); + return cssText.replace(urlRe, (orig, q, url) => { + const data = map.get(url); + return data ? 'url(' + q + data + q + ')' : orig; + }); + } + async function collectFontCssText() { + const chunks = []; + const fontFaceRe = /@font-face\s*\{[^}]*\}/g; + for (const sheet of document.styleSheets) { + try { + const rules = sheet.cssRules; + for (const rule of rules) { + if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) { + chunks.push(rule.cssText); + } + } + } catch { + if (!sheet.href) continue; + try { + const res = await fetch(sheet.href); + if (!res.ok) continue; + const text = await res.text(); + let m2; + while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]); + } catch { /* ignore; capture is best-effort */ } + } + } + if (chunks.length === 0) return ''; + return inlineFontUrls(chunks.join('\n')); + } + + // Capture the element (with current annotations baked in) and return a PNG + // Blob. Shared between the Go flow (uploads it to the server) and the + // debug toggle (displays it as an overlay for side-by-side comparison). + async function captureElementToBlob(el, snapshot, rect) { + try { if (document.fonts?.ready) await document.fonts.ready; } catch {} + const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); + let annotNode = null; + let savedPosition = null; + if (hasAnnotations) { + const pos = getComputedStyle(el).position; + if (pos === 'static') { + savedPosition = el.style.position; + el.style.position = 'relative'; + } + annotNode = buildAnnotationsForCapture(rect, snapshot); + el.appendChild(annotNode); + } + try { + const ms = await loadModernScreenshot(); + const fontCssText = await collectFontCssText(); + return await ms.domToBlob(el, { + scale: Math.min(window.devicePixelRatio || 1, 2), + backgroundColor: getComputedStyle(document.body).backgroundColor || '#ffffff', + font: fontCssText ? { cssText: fontCssText } : undefined, + }); + } finally { + if (annotNode) annotNode.remove(); + if (savedPosition !== null) el.style.position = savedPosition; + } + } + + async function captureAndEmit(el, basePayload, snapshot, rect) { + let screenshotPath; + let blob; + try { + blob = await captureElementToBlob(el, snapshot, rect); + } catch (err) { + console.warn('[impeccable] capture failed, proceeding without screenshot:', err); + } + // Light up the shader overlay the moment capture is ready — no reason to + // wait for the upload to complete before the user sees something alive. + if (blob && state === 'GENERATING') { + showShaderOverlay(el, blob, rect); + } + if (blob) { + try { + const uploadRes = await fetch( + 'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) + + '&eventId=' + encodeURIComponent(basePayload.id), + { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob }, + ); + if (uploadRes.ok) { + const { path: p } = await uploadRes.json(); + screenshotPath = p; + } else { + console.warn('[impeccable] annotation upload failed:', uploadRes.status); + } + } catch (err) { + console.warn('[impeccable] annotation upload failed:', err); + } + } + sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); + } + + // --------------------------------------------------------------------------- + // Shader overlay — renders the captured screenshot as a WebGL texture and + // runs an editorial "ink-wash" fragment shader over it during generation. + // A single rolling band sweeps top-to-bottom, desaturating + tinting magenta + // and leaving a soft trail. Makes the wait feel like a letterpress scan + // instead of a dead spinner. + // --------------------------------------------------------------------------- + + const SHADER_VS = `attribute vec2 a_position; +attribute vec2 a_uv; +varying vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_position, 0.0, 1.0); +}`; + + const SHADER_FS = `precision highp float; +uniform sampler2D u_texture; +uniform float u_time; +uniform vec2 u_resolution; +uniform vec3 u_accent; +varying vec2 v_uv; + +// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at +// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean +// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below" +// failure that reversed-edge smoothstep would give). +float bandAt(float d, float leadW, float trailW) { + float above = smoothstep(-leadW, 0.0, d); + float below = 1.0 - smoothstep(0.0, trailW, d); + return above * below; +} + +void main() { + vec2 uv = v_uv; + // Roller sweeps top-to-bottom with small overshoot so each cycle enters + // and exits the element cleanly. + float phase = fract(u_time / 3.4); + float y = phase * 1.25 - 0.12; + float band = bandAt(uv.y - y, 0.05, 0.32); + + // Halftone cell grid (fixed ~10 px pitch). + float cellPx = 10.0; + vec2 gridUv = uv * u_resolution / cellPx; + vec2 cellId = floor(gridUv); + vec2 cellUv = fract(gridUv) - 0.5; + vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution; + vec3 cellImg = texture2D(u_texture, sampleCenter).rgb; + float luma = dot(cellImg, vec3(0.299, 0.587, 0.114)); + // Darker cells → bigger magenta dots (classic risograph halftone curve). + float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56; + float dotMask = smoothstep(radius + 0.06, radius, length(cellUv)); + vec3 paper = vec3(0.975, 0.965, 0.955); + vec3 dotLayer = mix(paper, u_accent, dotMask); + + // Blend the halftone layer in where the roller is passing; leave the + // element pristine elsewhere. + vec3 base = texture2D(u_texture, uv).rgb; + gl_FragColor = vec4(mix(base, dotLayer, band), 1.0); +}`; + + // Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350)) + const SHADER_ACCENT = [0.82, 0.16, 0.47]; + let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + + function compileShader(gl, type, source) { + const sh = gl.createShader(type); + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(sh); + gl.deleteShader(sh); + throw new Error('shader compile failed: ' + info); + } + return sh; + } + + function positionShaderOverlay() { + if (!shaderState || !selectedElement) return; + const r = selectedElement.getBoundingClientRect(); + Object.assign(shaderState.canvas.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + } + + function hideShaderOverlay() { + if (!shaderState) return; + if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); + if (shaderState.canvas) shaderState.canvas.remove(); + const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + shaderState = null; + } + + async function showShaderOverlay(el, blob, rect) { + hideShaderOverlay(); + if (!blob || !el) return; + const canvas = document.createElement('canvas'); + canvas.id = PREFIX + '-shader'; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + Object.assign(canvas.style, { + position: 'fixed', + top: rect.top + 'px', left: rect.left + 'px', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', + zIndex: Z.bar - 1, + }); + document.body.appendChild(canvas); + + const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) + || canvas.getContext('experimental-webgl'); + if (!gl) { + // WebGL unavailable — fall back to a plain overlay so the user + // still sees something meaningful during generation. + canvas.remove(); + const img = document.createElement('img'); + img.src = URL.createObjectURL(blob); + img.id = PREFIX + '-shader'; + Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' }); + document.body.appendChild(img); + shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 }; + return; + } + + let program, texture; + try { + const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS); + program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error('program link failed: ' + gl.getProgramInfoLog(program)); + } + // Full-screen quad + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + -1, -1, 0, 1, + 1, -1, 1, 1, + -1, 1, 0, 0, + -1, 1, 0, 0, + 1, -1, 1, 1, + 1, 1, 1, 0, + ]), gl.STATIC_DRAW); + const posLoc = gl.getAttribLocation(program, 'a_position'); + const uvLoc = gl.getAttribLocation(program, 'a_uv'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(uvLoc); + gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8); + } catch (err) { + console.warn('[impeccable] shader setup failed:', err); + canvas.remove(); + return; + } + + // Upload the screenshot as a texture + let bitmap; + try { + bitmap = await createImageBitmap(blob); + } catch { + // Safari fallback: go via a regular Image + const imgUrl = URL.createObjectURL(blob); + const img = new Image(); + img.src = imgUrl; + await new Promise((r, rej) => { img.onload = r; img.onerror = rej; }); + bitmap = img; + URL.revokeObjectURL(imgUrl); + } + texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + if (bitmap.close) bitmap.close(); + + const uTime = gl.getUniformLocation(program, 'u_time'); + const uRes = gl.getUniformLocation(program, 'u_resolution'); + const uAccent = gl.getUniformLocation(program, 'u_accent'); + const uTex = gl.getUniformLocation(program, 'u_texture'); + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; + function frame() { + if (!shaderState) return; + const elapsed = (performance.now() - shaderState.startTime) / 1000; + const t = shaderState.reduced ? 0.0 : elapsed; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.useProgram(program); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.uniform1i(uTex, 0); + gl.uniform1f(uTime, t); + gl.uniform2f(uRes, canvas.width, canvas.height); + gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]); + gl.drawArrays(gl.TRIANGLES, 0, 6); + shaderState.rafId = requestAnimationFrame(frame); + } + frame(); } function handleAccept() { @@ -2713,6 +3600,7 @@ function init() { initHighlight(); + initAnnotOverlay(); initBar(); initActionPicker(); initGlobalBar(); diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs index 109aa768e..97163b255 100644 --- a/.rovodev/skills/impeccable/scripts/live-server.mjs +++ b/.rovodev/skills/impeccable/scripts/live-server.mjs @@ -18,7 +18,6 @@ import { randomUUID } from 'node:crypto'; import { spawn, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; @@ -56,8 +55,13 @@ const state = { pendingEvents: [], // browser events waiting for agent poll pendingPolls: [], // agent poll callbacks waiting for browser events exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots }; +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + function enqueueEvent(event) { if (state.pendingPolls.length > 0) { state.pendingPolls.shift()(event); @@ -134,6 +138,10 @@ function validateEvent(msg) { if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; return null; case 'accept': if (!msg.id) return 'accept: missing id'; @@ -175,6 +183,83 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { return; } + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + // --- Health --- if (p === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -250,10 +335,11 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } const absPath = path.resolve(process.cwd(), filePath); if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } - try { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(fs.readFileSync(absPath, 'utf-8')); - } catch { res.writeHead(404); res.end('File not found'); } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); return; } @@ -411,6 +497,9 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } for (const res of state.sseClients) { try { res.end(); } catch {} } state.sseClients.clear(); for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); @@ -442,12 +531,14 @@ Options: --help Show this help Endpoints: - /live.js Browser script (element picker + variant cycling) - /detect.js Detection overlay (backwards compatible) - /events SSE stream (server→browser) + POST (browser→server) - /poll Long-poll for agent CLI - /source Raw source file reader (no-HMR fallback) - /health Health check`); + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); process.exit(0); } @@ -531,6 +622,12 @@ try { state.token = randomUUID(); const portArg = args.find(a => a.startsWith('--port=')); state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); const { detectScript, liveScript } = loadBrowserScripts(); const liveScriptWithToken = diff --git a/.rovodev/skills/impeccable/scripts/modern-screenshot.umd.js b/.rovodev/skills/impeccable/scripts/modern-screenshot.umd.js new file mode 100644 index 000000000..a9c5208f6 --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/modern-screenshot.umd.js @@ -0,0 +1,14 @@ +(function(y,v){typeof exports=="object"&&typeof module!="undefined"?v(exports):typeof define=="function"&&define.amd?define(["exports"],v):(y=typeof globalThis!="undefined"?globalThis:y||self,v(y.modernScreenshot={}))})(this,function(y){"use strict";var rr=Object.defineProperty,nr=Object.defineProperties;var or=Object.getOwnPropertyDescriptors;var Z=Object.getOwnPropertySymbols;var xe=Object.prototype.hasOwnProperty,Me=Object.prototype.propertyIsEnumerable;var Oe=Math.pow,Le=(y,v,N)=>v in y?rr(y,v,{enumerable:!0,configurable:!0,writable:!0,value:N}):y[v]=N,D=(y,v)=>{for(var N in v||(v={}))xe.call(v,N)&&Le(y,N,v[N]);if(Z)for(var N of Z(v))Me.call(v,N)&&Le(y,N,v[N]);return y},M=(y,v)=>nr(y,or(v));var je=(y,v)=>{var N={};for(var R in y)xe.call(y,R)&&v.indexOf(R)<0&&(N[R]=y[R]);if(y!=null&&Z)for(var R of Z(y))v.indexOf(R)<0&&Me.call(y,R)&&(N[R]=y[R]);return N};var C=(y,v,N)=>new Promise((R,O)=>{var X=P=>{try{q(N.next(P))}catch(W){O(W)}},j=P=>{try{q(N.throw(P))}catch(W){O(W)}},q=P=>P.done?R(P.value):Promise.resolve(P.value).then(X,j);q((N=N.apply(y,v)).next())});var Be;function v(e,t){return e[13]=1,e[14]=t>>8,e[15]=t&255,e[16]=t>>8,e[17]=t&255,e}const N=112,R=72,O=89,X=115;let j;function q(){const e=new Int32Array(256);for(let t=0;t<256;t++){let r=t;for(let n=0;n<8;n++)r=r&1?3988292384^r>>>1:r>>>1;e[t]=r}return e}function P(e){let t=-1;j||(j=q());for(let r=0;r>>8;return t^-1}function W(e){const t=e.length-1;for(let r=t;r>=4;r--)if(e[r-4]===9&&e[r-3]===N&&e[r-2]===R&&e[r-1]===O&&e[r]===X)return r-3;return 0}function ae(e,t,r=!1){const n=new Uint8Array(13);t*=39.3701,n[0]=N,n[1]=R,n[2]=O,n[3]=X,n[4]=t>>>24,n[5]=t>>>16,n[6]=t>>>8,n[7]=t&255,n[8]=n[4],n[9]=n[5],n[10]=n[6],n[11]=n[7],n[12]=1;const i=P(n),a=new Uint8Array(4);if(a[0]=i>>>24,a[1]=i>>>16,a[2]=i>>>8,a[3]=i&255,r){const s=W(e);return e.set(n,s),e.set(a,s+13),e}else{const s=new Uint8Array(4);s[0]=0,s[1]=0,s[2]=0,s[3]=9;const o=new Uint8Array(54);return o.set(e,0),o.set(s,33),o.set(n,37),o.set(a,50),o}}const qe="AAlwSFlz",We="AAAJcEhZ",He="AAAACXBI";function Ve(e){let t=e.indexOf(qe);return t===-1&&(t=e.indexOf(We)),t===-1&&(t=e.indexOf(He)),t}const se="[modern-screenshot]",U=typeof window!="undefined",ze=U&&"Worker"in window,ie=U&&"atob"in window,Xe=U&&"btoa"in window,ee=U?(Be=window.navigator)==null?void 0:Be.userAgent:"",le=ee.includes("Chrome"),G=ee.includes("AppleWebKit")&&!le,te=ee.includes("Firefox"),Ge=e=>e&&"__CONTEXT__"in e,Ye=e=>e.constructor.name==="CSSFontFaceRule",Je=e=>e.constructor.name==="CSSImportRule",Ke=e=>e.constructor.name==="CSSLayerBlockRule",I=e=>e.nodeType===1,H=e=>typeof e.className=="object",ce=e=>e.tagName==="image",Qe=e=>e.tagName==="use",V=e=>I(e)&&typeof e.style!="undefined"&&!H(e),Ze=e=>e.nodeType===8,et=e=>e.nodeType===3,$=e=>e.tagName==="IMG",Y=e=>e.tagName==="VIDEO",tt=e=>e.tagName==="CANVAS",rt=e=>e.tagName==="TEXTAREA",nt=e=>e.tagName==="INPUT",ot=e=>e.tagName==="STYLE",at=e=>e.tagName==="SCRIPT",st=e=>e.tagName==="SELECT",it=e=>e.tagName==="SLOT",lt=e=>e.tagName==="IFRAME",ct=(...e)=>console.warn(se,...e);function ut(e){var r;const t=(r=e==null?void 0:e.createElement)==null?void 0:r.call(e,"canvas");return t&&(t.height=t.width=1),!!t&&"toDataURL"in t&&!!t.toDataURL("image/webp").includes("image/webp")}const re=e=>e.startsWith("data:");function ue(e,t){if(e.match(/^[a-z]+:\/\//i))return e;if(U&&e.match(/^\/\//))return window.location.protocol+e;if(e.match(/^[a-z]+:/i)||!U)return e;const r=J().implementation.createHTMLDocument(),n=r.createElement("base"),i=r.createElement("a");return r.head.appendChild(n),r.body.appendChild(i),t&&(n.href=t),i.href=e,i.href}function J(e){var t;return(t=e&&I(e)?e==null?void 0:e.ownerDocument:e)!=null?t:window.document}const K="http://www.w3.org/2000/svg";function fe(e,t,r){const n=J(r).createElementNS(K,"svg");return n.setAttributeNS(null,"width",e.toString()),n.setAttributeNS(null,"height",t.toString()),n.setAttributeNS(null,"viewBox",`0 0 ${e} ${t}`),n}function de(e,t){let r=new XMLSerializer().serializeToString(e);return t&&(r=r.replace(/[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu,"")),`data:image/svg+xml;charset=utf-8,${encodeURIComponent(r)}`}function ft(e,t="image/png",r=1){return C(this,null,function*(){try{return yield new Promise((n,i)=>{e.toBlob(a=>{a?n(a):i(new Error("Blob is null"))},t,r)})}catch(n){if(ie)return dt(e.toDataURL(t,r));throw n}})}function dt(e){var o,c;const[t,r]=e.split(","),n=(c=(o=t.match(/data:(.+);/))==null?void 0:o[1])!=null?c:void 0,i=window.atob(r),a=i.length,s=new Uint8Array(a);for(let u=0;u{const i=new FileReader;i.onload=()=>r(i.result),i.onerror=()=>n(i.error),i.onabort=()=>n(new Error(`Failed read blob to ${t}`)),t==="dataUrl"?i.readAsDataURL(e):t==="arrayBuffer"&&i.readAsArrayBuffer(e)})}const gt=e=>ge(e,"dataUrl"),mt=e=>ge(e,"arrayBuffer");function _(e,t){const r=J(t).createElement("img");return r.decoding="sync",r.loading="eager",r.src=e,r}function L(e,t){return new Promise(r=>{const{timeout:n,ownerDocument:i,onError:a,onWarn:s}=t!=null?t:{},o=typeof e=="string"?_(e,J(i)):e;let c=null,u=null;function l(){r(o),c&&clearTimeout(c),u==null||u()}if(n&&(c=setTimeout(l,n)),Y(o)){const d=o.currentSrc||o.src;if(!d)return o.poster?L(o.poster,t).then(r):l();if(o.readyState>=2)return l();const m=l,f=h=>{s==null||s("Failed video load",d,h),a==null||a(h),l()};u=()=>{o.removeEventListener("loadeddata",m),o.removeEventListener("error",f)},o.addEventListener("loadeddata",m,{once:!0}),o.addEventListener("error",f,{once:!0})}else{const d=ce(o)?o.href.baseVal:o.currentSrc||o.src;if(!d)return l();const m=()=>C(this,null,function*(){if($(o)&&"decode"in o)try{yield o.decode()}catch(h){s==null||s("Failed to decode image, trying to render anyway",o.dataset.originalSrc||d,h)}l()}),f=h=>{s==null||s("Failed image load",o.dataset.originalSrc||d,h),l()};if($(o)&&o.complete)return m();u=()=>{o.removeEventListener("load",m),o.removeEventListener("error",f)},o.addEventListener("load",m,{once:!0}),o.addEventListener("error",f,{once:!0})}})}function me(e,t){return C(this,null,function*(){V(e)&&($(e)||Y(e)?yield L(e,t):yield Promise.all(["img","video"].flatMap(r=>Array.from(e.querySelectorAll(r)).map(n=>L(n,t)))))})}const he=function(){let t=0;const r=()=>`0000${(Math.random()*Oe(36,4)<<0).toString(36)}`.slice(-4);return()=>(t+=1,`u${r()}${t}`)}();function we(e){return e==null?void 0:e.split(",").map(t=>t.trim().replace(/"|'/g,"").toLowerCase()).filter(Boolean)}let pe=0;function ht(e){const t=`${se}[#${pe}]`;return pe++,{time:r=>e&&console.time(`${t} ${r}`),timeEnd:r=>e&&console.timeEnd(`${t} ${r}`),warn:(...r)=>e&&ct(...r)}}function wt(e){return{cache:e?"no-cache":"force-cache"}}function k(e,t){return C(this,null,function*(){return Ge(e)?e:ye(e,M(D({},t),{autoDestruct:!0}))})}function ye(e,t){return C(this,null,function*(){var f,h,g,p,E;const{scale:r=1,workerUrl:n,workerNumber:i=1}=t||{},a=!!(t!=null&&t.debug),s=(f=t==null?void 0:t.features)!=null?f:!0,o=(h=e.ownerDocument)!=null?h:U?window.document:void 0,c=(p=(g=e.ownerDocument)==null?void 0:g.defaultView)!=null?p:U?window:void 0,u=new Map,l=M(D({width:0,height:0,quality:1,type:"image/png",scale:r,backgroundColor:null,style:null,filter:null,maximumCanvasSize:0,timeout:3e4,progress:null,debug:a,fetch:D({requestInit:wt((E=t==null?void 0:t.fetch)==null?void 0:E.bypassingCache),placeholderImage:"data:image/png;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",bypassingCache:!1},t==null?void 0:t.fetch),fetchFn:null,font:{},drawImageInterval:100,workerUrl:null,workerNumber:i,onCloneEachNode:null,onCloneNode:null,onEmbedNode:null,onCreateForeignObjectSvg:null,includeStyleProperties:null,autoDestruct:!1},t),{__CONTEXT__:!0,log:ht(a),node:e,ownerDocument:o,ownerWindow:c,dpi:r===1?null:96*r,svgStyleElement:be(o),svgDefsElement:o==null?void 0:o.createElementNS(K,"defs"),svgStyles:new Map,defaultComputedStyles:new Map,workers:[...Array.from({length:ze&&n&&i?i:0})].map(()=>{try{const b=new Worker(n);return b.onmessage=w=>C(this,null,function*(){var A,F,B,$e;const{url:S,result:T}=w.data;T?(F=(A=u.get(S))==null?void 0:A.resolve)==null||F.call(A,T):($e=(B=u.get(S))==null?void 0:B.reject)==null||$e.call(B,new Error(`Error receiving message from worker: ${S}`))}),b.onmessageerror=w=>{var T,A;const{url:S}=w.data;(A=(T=u.get(S))==null?void 0:T.reject)==null||A.call(T,new Error(`Error receiving message from worker: ${S}`))},b}catch(b){return l.log.warn("Failed to new Worker",b),null}}).filter(Boolean),fontFamilies:new Map,fontCssTexts:new Map,acceptOfImage:`${[ut(o)&&"image/webp","image/svg+xml","image/*","*/*"].filter(Boolean).join(",")};q=0.8`,requests:u,drawImageCount:0,tasks:[],features:s,isEnable:b=>{var w,S;return b==="restoreScrollPosition"?typeof s=="boolean"?!1:(w=s[b])!=null?w:!1:typeof s=="boolean"?s:(S=s[b])!=null?S:!0},shadowRoots:[]});l.log.time("wait until load"),yield me(e,{timeout:l.timeout,onWarn:l.log.warn}),l.log.timeEnd("wait until load");const{width:d,height:m}=pt(e,l);return l.width=d,l.height=m,l})}function be(e){if(!e)return;const t=e.createElement("style"),r=t.ownerDocument.createTextNode(` +.______background-clip--text { + background-clip: text; + -webkit-background-clip: text; +} +`);return t.appendChild(r),t}function pt(e,t){let{width:r,height:n}=t;if(I(e)&&(!r||!n)){const i=e.getBoundingClientRect();r=r||i.width||Number(e.getAttribute("width"))||0,n=n||i.height||Number(e.getAttribute("height"))||0}return{width:r,height:n}}function yt(e,t){return C(this,null,function*(){const{log:r,timeout:n,drawImageCount:i,drawImageInterval:a}=t;r.time("image to canvas");const s=yield L(e,{timeout:n,onWarn:t.log.warn}),{canvas:o,context2d:c}=bt(e.ownerDocument,t),u=()=>{try{c==null||c.drawImage(s,0,0,o.width,o.height)}catch(l){t.log.warn("Failed to drawImage",l)}};if(u(),t.isEnable("fixSvgXmlDecode"))for(let l=0;l{setTimeout(()=>{c==null||c.clearRect(0,0,o.width,o.height),u(),d()},l+a)});return t.drawImageCount=0,r.timeEnd("image to canvas"),o})}function bt(e,t){const{width:r,height:n,scale:i,backgroundColor:a,maximumCanvasSize:s}=t,o=e.createElement("canvas");o.width=Math.floor(r*i),o.height=Math.floor(n*i),o.style.width=`${r}px`,o.style.height=`${n}px`,s&&(o.width>s||o.height>s)&&(o.width>s&&o.height>s?o.width>o.height?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s):o.width>s?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s));const c=o.getContext("2d");return c&&a&&(c.fillStyle=a,c.fillRect(0,0,o.width,o.height)),{canvas:o,context2d:c}}function Se(e,t){if(e.ownerDocument)try{const a=e.toDataURL();if(a!=="data:,")return _(a,e.ownerDocument)}catch(a){t.log.warn("Failed to clone canvas",a)}const r=e.cloneNode(!1),n=e.getContext("2d"),i=r.getContext("2d");try{return n&&i&&i.putImageData(n.getImageData(0,0,e.width,e.height),0,0),r}catch(a){t.log.warn("Failed to clone canvas",a)}return r}function St(e,t){var r;try{if((r=e==null?void 0:e.contentDocument)!=null&&r.documentElement)return ne(e.contentDocument.documentElement,t)}catch(n){t.log.warn("Failed to clone iframe",n)}return e.cloneNode(!1)}function Et(e){const t=e.cloneNode(!1);return e.currentSrc&&e.currentSrc!==e.src&&(t.src=e.currentSrc,t.srcset=""),t.loading==="lazy"&&(t.loading="eager"),t}function Ct(e,t){return C(this,null,function*(){if(e.ownerDocument&&!e.currentSrc&&e.poster)return _(e.poster,e.ownerDocument);const r=e.cloneNode(!1);r.crossOrigin="anonymous",e.currentSrc&&e.currentSrc!==e.src&&(r.src=e.currentSrc);const n=r.ownerDocument;if(n){let i=!0;if(yield L(r,{onError:()=>i=!1,onWarn:t.log.warn}),!i)return e.poster?_(e.poster,e.ownerDocument):r;r.currentTime=e.currentTime,yield new Promise(s=>{r.addEventListener("seeked",s,{once:!0})});const a=n.createElement("canvas");a.width=e.offsetWidth,a.height=e.offsetHeight;try{const s=a.getContext("2d");s&&s.drawImage(r,0,0,a.width,a.height)}catch(s){return t.log.warn("Failed to clone video",s),e.poster?_(e.poster,e.ownerDocument):r}return Se(a,t)}return r})}function Tt(e,t){return tt(e)?Se(e,t):lt(e)?St(e,t):$(e)?Et(e):Y(e)?Ct(e,t):e.cloneNode(!1)}function vt(e){let t=e.sandbox;if(!t){const{ownerDocument:r}=e;try{r&&(t=r.createElement("iframe"),t.id=`__SANDBOX__${he()}`,t.width="0",t.height="0",t.style.visibility="hidden",t.style.position="fixed",r.body.appendChild(t),t.srcdoc='',e.sandbox=t)}catch(n){e.log.warn("Failed to getSandBox",n)}}return t}const At=["width","height","-webkit-text-fill-color"],Nt=["stroke","fill"];function Ee(e,t,r){const{defaultComputedStyles:n}=r,i=e.nodeName.toLowerCase(),a=H(e)&&i!=="svg",s=a?Nt.map(g=>[g,e.getAttribute(g)]).filter(([,g])=>g!==null):[],o=[a&&"svg",i,s.map((g,p)=>`${g}=${p}`).join(","),t].filter(Boolean).join(":");if(n.has(o))return n.get(o);const c=vt(r),u=c==null?void 0:c.contentWindow;if(!u)return new Map;const l=u==null?void 0:u.document;let d,m;a?(d=l.createElementNS(K,"svg"),m=d.ownerDocument.createElementNS(d.namespaceURI,i),s.forEach(([g,p])=>{m.setAttributeNS(null,g,p)}),d.appendChild(m)):d=m=l.createElement(i),m.textContent=" ",l.body.appendChild(d);const f=u.getComputedStyle(m,t),h=new Map;for(let g=f.length,p=0;pn.set(d,l));function s(c){const u=e.getPropertyValue(c),l=e.getPropertyPriority(c),d=c.lastIndexOf("-"),m=d>-1?c.substring(0,d):void 0;if(m){let f=a.get(m);f||(f=new Map,a.set(m,f)),f.set(c,[u,l])}t.get(c)===u&&!l||(m?i.push(m):n.set(c,[u,l]))}return n}function Rt(e,t,r,n){var d,m,f,h;const{ownerWindow:i,includeStyleProperties:a,currentParentNodeStyle:s}=n,o=t.style,c=i.getComputedStyle(e),u=Ee(e,null,n);s==null||s.forEach((g,p)=>{u.delete(p)});const l=Ce(c,u,a);l.delete("transition-property"),l.delete("all"),l.delete("d"),l.delete("content"),r&&(l.delete("position"),l.delete("margin-top"),l.delete("margin-right"),l.delete("margin-bottom"),l.delete("margin-left"),l.delete("margin-block-start"),l.delete("margin-block-end"),l.delete("margin-inline-start"),l.delete("margin-inline-end"),l.set("box-sizing",["border-box",""])),((d=l.get("background-clip"))==null?void 0:d[0])==="text"&&t.classList.add("______background-clip--text"),le&&(l.has("font-kerning")||l.set("font-kerning",["normal",""]),(((m=l.get("overflow-x"))==null?void 0:m[0])==="hidden"||((f=l.get("overflow-y"))==null?void 0:f[0])==="hidden")&&((h=l.get("text-overflow"))==null?void 0:h[0])==="ellipsis"&&e.scrollWidth===e.clientWidth&&l.set("text-overflow",["clip",""]));for(let g=o.length,p=0;p{o.setProperty(E,g,p)}),l}function It(e,t){(rt(e)||nt(e)||st(e))&&t.setAttribute("value",e.value)}const kt=["::before","::after"],Dt=["::-webkit-scrollbar","::-webkit-scrollbar-button","::-webkit-scrollbar-thumb","::-webkit-scrollbar-track","::-webkit-scrollbar-track-piece","::-webkit-scrollbar-corner","::-webkit-resizer"];function Pt(e,t,r,n,i){const{ownerWindow:a,svgStyleElement:s,svgStyles:o,currentNodeStyle:c}=n;if(!s||!a)return;function u(l){var w;const d=a.getComputedStyle(e,l);let m=d.getPropertyValue("content");if(!m||m==="none")return;i==null||i(m),m=m.replace(/(')|(")|(counter\(.+\))/g,"");const f=[he()],h=Ee(e,l,n);c==null||c.forEach((S,T)=>{h.delete(T)});const g=Ce(d,h,n.includeStyleProperties);g.delete("content"),g.delete("-webkit-locale"),((w=g.get("background-clip"))==null?void 0:w[0])==="text"&&t.classList.add("______background-clip--text");const p=[`content: '${m}';`];if(g.forEach(([S,T],A)=>{p.push(`${A}: ${S}${T?" !important":""};`)}),p.length===1)return;try{t.className=[t.className,...f].join(" ")}catch(S){n.log.warn("Failed to copyPseudoClass",S);return}const E=p.join(` + `);let b=o.get(E);b||(b=[],o.set(E,b)),b.push(`.${f[0]}${l}`)}kt.forEach(u),r&&Dt.forEach(u)}const Te=new Set(["symbol"]);function ve(e,t,r,n,i){return C(this,null,function*(){if(I(r)&&(ot(r)||at(r))||n.filter&&!n.filter(r))return;Te.has(t.nodeName)||Te.has(r.nodeName)?n.currentParentNodeStyle=void 0:n.currentParentNodeStyle=n.currentNodeStyle;const a=yield ne(r,n,!1,i);n.isEnable("restoreScrollPosition")&&Ut(e,a),t.appendChild(a)})}function Ae(e,t,r,n){return C(this,null,function*(){var a;let i=e.firstChild;I(e)&&e.shadowRoot&&(i=(a=e.shadowRoot)==null?void 0:a.firstChild,r.shadowRoots.push(e.shadowRoot));for(let s=i;s;s=s.nextSibling)if(!Ze(s))if(I(s)&&it(s)&&typeof s.assignedNodes=="function"){const o=s.assignedNodes();for(let c=0;ce.clientHeight||e.scrollWidth>e.clientWidth)}const p=(d=h.get("text-transform"))==null?void 0:d[0],E=we((m=h.get("font-family"))==null?void 0:m[0]),b=E?w=>{p==="uppercase"?w=w.toUpperCase():p==="lowercase"?w=w.toLowerCase():p==="capitalize"&&(w=w[0].toUpperCase()+w.substring(1)),E.forEach(S=>{let T=s.get(S);T||s.set(S,T=new Set),w.split("").forEach(A=>T.add(A))})}:void 0;return Pt(e,f,g,t,b),It(e,f),Y(e)||(yield Ae(e,f,t,b)),yield o==null?void 0:o(f),f}const c=e.cloneNode(!1);return yield Ae(e,c,t),yield o==null?void 0:o(c),c})}function Ne(e){if(e.ownerDocument=void 0,e.ownerWindow=void 0,e.svgStyleElement=void 0,e.svgDefsElement=void 0,e.svgStyles.clear(),e.defaultComputedStyles.clear(),e.sandbox){try{e.sandbox.remove()}catch(t){e.log.warn("Failed to destroyContext",t)}e.sandbox=void 0}e.workers=[],e.fontFamilies.clear(),e.fontCssTexts.clear(),e.requests.clear(),e.tasks=[],e.shadowRoots=[]}function Bt(e){const o=e,{url:t,timeout:r,responseType:n}=o,i=je(o,["url","timeout","responseType"]),a=new AbortController,s=r?setTimeout(()=>a.abort(),r):void 0;return fetch(t,D({signal:a.signal},i)).then(c=>{if(!c.ok)throw new Error("Failed fetch, not 2xx response",{cause:c});switch(n){case"arrayBuffer":return c.arrayBuffer();case"dataUrl":return c.blob().then(gt);case"text":default:return c.text()}}).finally(()=>clearTimeout(s))}function z(e,t){const{url:r,requestType:n="text",responseType:i="text",imageDom:a}=t;let s=r;const{timeout:o,acceptOfImage:c,requests:u,fetchFn:l,fetch:{requestInit:d,bypassingCache:m,placeholderImage:f},font:h,workers:g,fontFamilies:p}=e;n==="image"&&(G||te)&&e.drawImageCount++;let E=u.get(r);if(!E){m&&m instanceof RegExp&&m.test(s)&&(s+=(/\?/.test(s)?"&":"?")+new Date().getTime());const b=n.startsWith("font")&&h&&h.minify,w=new Set;b&&n.split(";")[1].split(",").forEach(F=>{p.has(F)&&p.get(F).forEach(B=>w.add(B))});const S=b&&w.size,T=D({url:s,timeout:o,responseType:S?"arrayBuffer":i,headers:n==="image"?{accept:c}:void 0},d);E={type:n,resolve:void 0,reject:void 0,response:null},E.response=C(this,null,function*(){if(l&&n==="image"){const A=yield l(r);if(A)return A}return!G&&r.startsWith("http")&&g.length?new Promise((A,F)=>{g[u.size&g.length-1].postMessage(D({rawUrl:r},T)),E.resolve=A,E.reject=F}):Bt(T)}).catch(A=>{if(u.delete(r),n==="image"&&f)return e.log.warn("Failed to fetch image base64, trying to use placeholder image",s),typeof f=="string"?f:f(a);throw A}),u.set(r,E)}return E.response}function Re(e,t,r,n){return C(this,null,function*(){if(!Ie(e))return e;for(const[i,a]of $t(e,t))try{const s=yield z(r,{url:a,requestType:n?"image":"text",responseType:"dataUrl"});e=e.replace(Lt(i),`$1${s}$3`)}catch(s){r.log.warn("Failed to fetch css data url",i,s)}return e})}function Ie(e){return/url\((['"]?)([^'"]+?)\1\)/.test(e)}const ke=/url\((['"]?)([^'"]+?)\1\)/g;function $t(e,t){const r=[];return e.replace(ke,(n,i,a)=>(r.push([a,ue(a,t)]),n)),r.filter(([n])=>!re(n))}function Lt(e){const t=e.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${t})(['"]?\\))`,"g")}const xt=["background-image","border-image-source","-webkit-border-image","-webkit-mask-image","list-style-image"];function Mt(e,t){return xt.map(r=>{const n=e.getPropertyValue(r);return!n||n==="none"?null:((G||te)&&t.drawImageCount++,Re(n,null,t,!0).then(i=>{!i||n===i||e.setProperty(r,i,e.getPropertyPriority(r))}))}).filter(Boolean)}function Ot(e,t){if($(e)){const r=e.currentSrc||e.src;if(!re(r))return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.srcset="",e.dataset.originalSrc=r,e.src=n||"")})];(G||te)&&t.drawImageCount++}else if(H(e)&&!re(e.href.baseVal)){const r=e.href.baseVal;return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.dataset.originalSrc=r,e.href.baseVal=n||"")})]}return[]}function jt(e,t){var o;const{ownerDocument:r,svgDefsElement:n}=t,i=(o=e.getAttribute("href"))!=null?o:e.getAttribute("xlink:href");if(!i)return[];const[a,s]=i.split("#");if(s){const c=`#${s}`,u=t.shadowRoots.reduce((l,d)=>l!=null?l:d.querySelector(`svg ${c}`),r==null?void 0:r.querySelector(`svg ${c}`));if(a&&e.setAttribute("href",c),n!=null&&n.querySelector(c))return[];if(u)return n==null||n.appendChild(u.cloneNode(!0)),[];if(a)return[z(t,{url:a,responseType:"text"}).then(l=>{n==null||n.insertAdjacentHTML("beforeend",l)})]}return[]}function De(e,t){const{tasks:r}=t;I(e)&&(($(e)||ce(e))&&r.push(...Ot(e,t)),Qe(e)&&r.push(...jt(e,t))),V(e)&&r.push(...Mt(e.style,t)),e.childNodes.forEach(n=>{De(n,t)})}function qt(e,t){return C(this,null,function*(){const{ownerDocument:r,svgStyleElement:n,fontFamilies:i,fontCssTexts:a,tasks:s,font:o}=t;if(!(!r||!n||!i.size))if(o&&o.cssText){const c=Ue(o.cssText,t);n.appendChild(r.createTextNode(`${c} +`))}else{const c=Array.from(r.styleSheets).filter(f=>{try{return"cssRules"in f&&!!f.cssRules.length}catch(h){return t.log.warn(`Error while reading CSS rules from ${f.href}`,h),!1}}),u=r.implementation.createHTMLDocument(""),l=u.createElement("style");u.head.appendChild(l);const d=l.sheet;yield Promise.all(c.flatMap(f=>Array.from(f.cssRules).map(h=>C(this,null,function*(){if(Je(h)){const g=h.href;let p="";try{p=yield z(t,{url:g,requestType:"text",responseType:"text"})}catch(b){t.log.warn(`Error fetch remote css import from ${g}`,b)}const E=p.replace(ke,(b,w,S)=>b.replace(S,ue(S,g)));for(const b of Ht(E))try{d.insertRule(b,d.cssRules.length)}catch(w){t.log.warn("Error inserting rule from remote css import",{rule:b,error:w})}}})))),d.cssRules.length&&c.push(d);const m=[];c.forEach(f=>{oe(f.cssRules,m)}),m.filter(f=>{var h;return Ye(f)&&Ie(f.style.getPropertyValue("src"))&&((h=we(f.style.getPropertyValue("font-family")))==null?void 0:h.some(g=>i.has(g)))}).forEach(f=>{const h=f,g=a.get(h.cssText);g?n.appendChild(r.createTextNode(`${g} +`)):s.push(Re(h.cssText,h.parentStyleSheet?h.parentStyleSheet.href:null,t).then(p=>{p=Ue(p,t),a.set(h.cssText,p),n.appendChild(r.createTextNode(`${p} +`))}))})}})}const Wt=/(\/\*[\s\S]*?\*\/)/g,Pe=/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi;function Ht(e){if(e==null)return[];const t=[];let r=e.replace(Wt,"");for(;;){const a=Pe.exec(r);if(!a)break;t.push(a[0])}r=r.replace(Pe,"");const n=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,i=new RegExp("((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})","gi");for(;;){let a=n.exec(r);if(a)i.lastIndex=n.lastIndex;else if(a=i.exec(r),a)n.lastIndex=i.lastIndex;else break;t.push(a[0])}return t}const Vt=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,zt=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Ue(e,t){const{font:r}=t,n=r?r==null?void 0:r.preferredFormat:void 0;return n?e.replace(zt,i=>{for(;;){const[a,,s]=Vt.exec(i)||[];if(!s)return"";if(s===n)return`src: ${a};`}}):e}function oe(e,t=[]){for(const r of Array.from(e))Ke(r)?t.push(...oe(r.cssRules)):"cssRules"in r?oe(r.cssRules,t):t.push(r);return t}const Xt=/\bx?link:?href\s*=\s*["'](?!data:)[^"']+["']/i;function Gt(e){return Xt.test(e.innerHTML)}function _e(e,t){return C(this,null,function*(){const r=yield k(e,t);if(I(r.node)&&H(r.node)&&!Gt(r.node))return r.node;const{ownerDocument:n,log:i,tasks:a,svgStyleElement:s,svgDefsElement:o,svgStyles:c,font:u,progress:l,autoDestruct:d,onCloneNode:m,onEmbedNode:f,onCreateForeignObjectSvg:h}=r;i.time("clone node");const g=yield ne(r.node,r,!0);if(s&&n){let S="";c.forEach((T,A)=>{S+=`${T.join(`, +`)} { + ${A} +} +`}),s.appendChild(n.createTextNode(S))}i.timeEnd("clone node"),yield m==null?void 0:m(g),u!==!1&&I(g)&&(i.time("embed web font"),yield qt(g,r),i.timeEnd("embed web font")),i.time("embed node"),De(g,r);const p=a.length;let E=0;const b=()=>C(this,null,function*(){for(;;){const S=a.pop();if(!S)break;try{yield S}catch(T){r.log.warn("Failed to run task",T)}l==null||l(++E,p)}});l==null||l(E,p),yield Promise.all([...Array.from({length:4})].map(b)),i.timeEnd("embed node"),yield f==null?void 0:f(g);const w=Yt(g,r);return o&&w.insertBefore(o,w.children[0]),s&&w.insertBefore(s,w.children[0]),d&&Ne(r),yield h==null?void 0:h(w),w})}function Yt(e,t){const{width:r,height:n}=t,i=fe(r,n,e.ownerDocument),a=i.ownerDocument.createElementNS(i.namespaceURI,"foreignObject");return a.setAttributeNS(null,"x","0%"),a.setAttributeNS(null,"y","0%"),a.setAttributeNS(null,"width","100%"),a.setAttributeNS(null,"height","100%"),a.append(e),i.appendChild(a),i}function Q(e,t){return C(this,null,function*(){var s;const r=yield k(e,t),n=yield _e(r),i=de(n,r.isEnable("removeControlCharacter"));r.autoDestruct||(r.svgStyleElement=be(r.ownerDocument),r.svgDefsElement=(s=r.ownerDocument)==null?void 0:s.createElementNS(K,"defs"),r.svgStyles.clear());const a=_(i,n.ownerDocument);return yield yt(a,r)})}function Jt(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,type:i,quality:a,dpi:s}=r,o=yield Q(r);n.time("canvas to blob");const c=yield ft(o,i,a);if(["image/png","image/jpeg"].includes(i)&&s){const u=yield mt(c.slice(0,33));let l=new Uint8Array(u);return i==="image/png"?l=ae(l,s):i==="image/jpeg"&&(l=v(l,s)),n.timeEnd("canvas to blob"),new Blob([l,c.slice(33)],{type:i})}return n.timeEnd("canvas to blob"),c})}function x(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,quality:i,type:a,dpi:s}=r,o=yield Q(r);n.time("canvas to data url");let c=o.toDataURL(a,i);if(["image/png","image/jpeg"].includes(a)&&s&&ie&&Xe){const[u,l]=c.split(",");let d=0,m=!1;if(a==="image/png"){const w=Ve(l);w>=0?(d=Math.ceil((w+28)/3)*4,m=!0):d=33/3*4}else a==="image/jpeg"&&(d=18/3*4);const f=l.substring(0,d),h=l.substring(d),g=window.atob(f),p=new Uint8Array(g.length);for(let w=0;w mirroring selectedElement's bounding rect. Click (no + // drag) drops a comment pin; drag paints a magenta SVG stroke. All coords + // are stored in element-local CSS px so they survive scroll / resize and + // correlate directly with the captured PNG. + // --------------------------------------------------------------------------- + + const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click + const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it + let annotOverlayEl = null; + let annotSvgEl = null; + let annotPinsEl = null; + let annotClearChipEl = null; + let annotState = { comments: [], strokes: [] }; + let annotActive = false; + // `annotPointer` is either: + // { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin + // { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin + let annotPointer = null; + let annotEditing = null; // { idx, input, wrapEl } + let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete + + function initAnnotOverlay() { + annotOverlayEl = document.createElement('div'); + annotOverlayEl.id = PREFIX + '-annot'; + Object.assign(annotOverlayEl.style, { + position: 'fixed', top: '0', left: '0', width: '0', height: '0', + pointerEvents: 'auto', zIndex: Z.highlight + 2, + display: 'none', overflow: 'visible', + cursor: 'crosshair', touchAction: 'none', + }); + + annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + annotSvgEl.id = PREFIX + '-annot-svg'; + Object.assign(annotSvgEl.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', + // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via + // pointer-events=stroke so gaps still fall through to the overlay. + pointerEvents: 'none', overflow: 'visible', + }); + annotOverlayEl.appendChild(annotSvgEl); + + annotPinsEl = document.createElement('div'); + annotPinsEl.id = PREFIX + '-annot-pins'; + Object.assign(annotPinsEl.style, { + position: 'absolute', inset: '0', + pointerEvents: 'none', + }); + annotOverlayEl.appendChild(annotPinsEl); + + annotClearChipEl = document.createElement('div'); + annotClearChipEl.id = PREFIX + '-annot-clear'; + annotClearChipEl.dataset.annotClear = 'true'; + annotClearChipEl.textContent = 'Clear'; + Object.assign(annotClearChipEl.style, { + position: 'absolute', top: '8px', right: '8px', + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '10px', fontWeight: '500', + letterSpacing: '0.08em', textTransform: 'uppercase', + padding: '5px 12px', borderRadius: '999px', + cursor: 'pointer', pointerEvents: 'auto', + display: 'none', userSelect: 'none', + boxShadow: '0 1px 3px rgba(0,0,0,0.2)', + }); + annotOverlayEl.appendChild(annotClearChipEl); + + annotOverlayEl.addEventListener('pointerdown', onAnnotDown); + annotOverlayEl.addEventListener('pointermove', onAnnotMove); + annotOverlayEl.addEventListener('pointerup', onAnnotUp); + annotOverlayEl.addEventListener('pointercancel', onAnnotUp); + document.body.appendChild(annotOverlayEl); + } + + function updateClearChip() { + if (!annotClearChipEl) return; + const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0; + annotClearChipEl.style.display = hasAny ? 'block' : 'none'; + } + + function showAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + annotActive = true; + positionAnnotOverlay(el); + annotOverlayEl.style.display = 'block'; + } + + function hideAnnotOverlay() { + annotActive = false; + if (annotOverlayEl) annotOverlayEl.style.display = 'none'; + // Drop any in-progress edit without touching annotState — clearAnnotations + // (if the caller is exiting configure mode) handles state reset. + annotEditing = null; + } + + function positionAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + const r = el.getBoundingClientRect(); + Object.assign(annotOverlayEl.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height); + } + + function clearAnnotations() { + annotState.comments = []; + annotState.strokes = []; + if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + if (annotPinsEl) annotPinsEl.innerHTML = ''; + annotPointer = null; + annotEditing = null; + annotLastPinClick = { idx: -1, time: 0 }; + updateClearChip(); + } + + // Rebuild the SVG layer. Each stroke gets a wider invisible hit path + // beneath the visible magenta path so clicks register on thin lines. + function redrawStrokes() { + while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + annotState.strokes.forEach((s, idx) => { + const d = pointsToPath(s.points); + const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + hit.setAttribute('d', d); + hit.setAttribute('stroke', 'transparent'); + hit.setAttribute('stroke-width', '16'); + hit.setAttribute('stroke-linecap', 'round'); + hit.setAttribute('stroke-linejoin', 'round'); + hit.setAttribute('fill', 'none'); + hit.setAttribute('pointer-events', 'stroke'); + hit.style.cursor = 'pointer'; + hit.dataset.annotStroke = String(idx); + annotSvgEl.appendChild(hit); + const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + visible.setAttribute('d', d); + visible.setAttribute('stroke', C.brand); + visible.setAttribute('stroke-width', '3'); + visible.setAttribute('stroke-linecap', 'round'); + visible.setAttribute('stroke-linejoin', 'round'); + visible.setAttribute('fill', 'none'); + visible.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(visible); + }); + updateClearChip(); + } + + function localCoords(e) { + const rect = annotOverlayEl.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + function onAnnotDown(e) { + if (!annotActive) return; + + // 1) Clear chip → wipe all annotations + if (e.target.closest?.('[data-annot-clear]')) { + if (annotEditing) annotEditing = null; + clearAnnotations(); + renderAllPins(); + redrawStrokes(); + e.stopPropagation(); e.preventDefault(); + return; + } + + // 2) Stroke hit path → delete that stroke + const strokeHit = e.target.closest?.('[data-annot-stroke]'); + if (strokeHit) { + const idx = parseInt(strokeHit.dataset.annotStroke, 10); + if (Number.isInteger(idx)) { + annotState.strokes.splice(idx, 1); + redrawStrokes(); + } + e.stopPropagation(); e.preventDefault(); + return; + } + + // 3) Pin → drag, edit, or delete-on-double-click + const pinWrap = e.target.closest?.('[data-annot-pin]'); + if (pinWrap) { + const idx = parseInt(pinWrap.dataset.annotPin, 10); + if (!Number.isInteger(idx)) return; + // Double-click (two pointerdowns on the same pin within window) → delete. + const now = Date.now(); + if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) { + if (annotEditing && annotEditing.idx === idx) annotEditing = null; + annotState.comments.splice(idx, 1); + annotLastPinClick = { idx: -1, time: 0 }; + renderAllPins(); + e.stopPropagation(); e.preventDefault(); + return; + } + annotLastPinClick = { idx, time: now }; + // If editing a different pin, commit that edit before starting here. + if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin(); + // If already editing THIS pin and the user clicked the dot, let the + // input keep focus (don't start a drag — the click wasn't meant as one). + if (annotEditing && annotEditing.idx === idx) return; + const p = localCoords(e); + const pin = annotState.comments[idx]; + annotPointer = { + kind: 'pin', idx, + startPointer: p, + startPin: { x: pin.x, y: pin.y }, + moved: false, + }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + return; + } + + // 4) Empty area → commit any open edit, then start new annotation + if (annotEditing) { + finalizeEditingPin(); + e.stopPropagation(); e.preventDefault(); + return; + } + const p = localCoords(e); + annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + } + + function onAnnotMove(e) { + if (!annotActive || !annotPointer) return; + const p = localCoords(e); + + if (annotPointer.kind === 'pin') { + const dx = p.x - annotPointer.startPointer.x; + const dy = p.y - annotPointer.startPointer.y; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + } + const pin = annotState.comments[annotPointer.idx]; + if (!pin) { annotPointer = null; return; } + pin.x = annotPointer.startPin.x + dx; + pin.y = annotPointer.startPin.y + dy; + renderAllPins(); + e.stopPropagation(); + return; + } + + // kind === 'new' + const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + strokeEl.setAttribute('stroke', C.brand); + strokeEl.setAttribute('stroke-width', '3'); + strokeEl.setAttribute('stroke-linecap', 'round'); + strokeEl.setAttribute('stroke-linejoin', 'round'); + strokeEl.setAttribute('fill', 'none'); + strokeEl.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(strokeEl); + annotPointer.strokeEl = strokeEl; + annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]]; + } + annotPointer.strokePoints.push([p.x, p.y]); + annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints)); + e.stopPropagation(); + } + + function onAnnotUp(e) { + if (!annotActive || !annotPointer) return; + + if (annotPointer.kind === 'pin') { + const wasDrag = annotPointer.moved; + const idx = annotPointer.idx; + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + if (wasDrag) { + // A drag is an intentional reposition; a follow-up click shouldn't be + // interpreted as a double-click-to-delete. + annotLastPinClick = { idx: -1, time: 0 }; + } else { + beginEditPin(idx); + } + e.stopPropagation(); + return; + } + + // kind === 'new' + const wasDrag = annotPointer.moved; + if (wasDrag) { + annotState.strokes.push({ points: annotPointer.strokePoints }); + // Swap the temporary preview SVG path for the full render with hit paths. + redrawStrokes(); + } else { + const idx = annotState.comments.length; + annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' }); + renderAllPins(); + beginEditPin(idx); + } + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + e.stopPropagation(); + } + + function pointsToPath(points) { + if (!points || points.length === 0) return ''; + let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1); + for (let i = 1; i < points.length; i++) { + d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1); + } + return d; + } + + function renderAllPins() { + annotPinsEl.innerHTML = ''; + annotState.comments.forEach((c, idx) => { + annotPinsEl.appendChild(buildPinElement(c, idx)); + }); + updateClearChip(); + } + + function buildPinElement(comment, idx) { + const interactive = idx >= 0; + const wrap = document.createElement('div'); + if (interactive) wrap.dataset.annotPin = String(idx); + Object.assign(wrap.style, { + position: 'absolute', + left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px', + pointerEvents: interactive ? 'auto' : 'none', + display: 'flex', alignItems: 'flex-start', gap: '6px', + cursor: interactive ? 'grab' : 'default', + touchAction: 'none', + }); + const dot = document.createElement('div'); + Object.assign(dot.style, { + width: '14px', height: '14px', borderRadius: '50%', + background: C.brand, border: '2px solid ' + C.white, + boxShadow: '0 1px 3px rgba(0,0,0,0.25)', + flexShrink: '0', + }); + wrap.appendChild(dot); + + if (comment.text) { + const bubble = document.createElement('div'); + bubble.textContent = comment.text; + Object.assign(bubble.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + marginTop: '-2px', maxWidth: '220px', + pointerEvents: 'none', whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }); + wrap.appendChild(bubble); + } + return wrap; + } + + function beginEditPin(idx) { + const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]'); + if (!wrapEl) return; + // Strip any existing bubble (but keep the dot) + wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove()); + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Note…'; + Object.assign(input.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + border: '1px solid ' + C.brand, + outline: 'none', marginTop: '-2px', + width: '220px', pointerEvents: 'auto', + }); + const originalText = annotState.comments[idx].text || ''; + input.value = originalText; + wrapEl.appendChild(input); + annotEditing = { idx, input, wrapEl, originalText }; + input.addEventListener('keydown', onAnnotInputKey, true); + input.addEventListener('blur', () => { + // Fires on both focus-loss and programmatic blur; commit unless we + // already handled it. + if (annotEditing && annotEditing.input === input) finalizeEditingPin(); + }); + // Stop clicks/pointerdowns inside the input from bubbling to the overlay + ['pointerdown', 'click'].forEach(ev => { + input.addEventListener(ev, e => e.stopPropagation()); + }); + setTimeout(() => input.focus(), 0); + } + + function onAnnotInputKey(e) { + if (e.key === 'Enter') { + e.preventDefault(); e.stopPropagation(); + finalizeEditingPin(); + } else if (e.key === 'Escape') { + e.preventDefault(); e.stopPropagation(); + cancelEditingPin(); + } else { + // Keep arrows / backspace from hitting global handlers + e.stopPropagation(); + } + } + + function finalizeEditingPin() { + if (!annotEditing) return; + const { idx, input } = annotEditing; + const text = input.value.trim(); + annotEditing = null; + if (text) annotState.comments[idx].text = text; + else annotState.comments.splice(idx, 1); + renderAllPins(); + } + + function cancelEditingPin() { + if (!annotEditing) return; + const { idx, originalText } = annotEditing; + annotEditing = null; + // If the pin had text before this edit, revert to it. If it was a + // just-created empty pin, Escape removes it. + if (originalText) { + annotState.comments[idx].text = originalText; + } else { + annotState.comments.splice(idx, 1); + } + renderAllPins(); + } + + // Build a detached annotation subtree suitable for injection into the clone + // modern-screenshot creates. Coordinates are element-local so this slots + // straight into an element that's been made position:relative. Takes an + // explicit snapshot so it works after annotState has been cleared. + function buildAnnotationsForCapture(rect, snapshot) { + const comments = snapshot ? snapshot.comments : annotState.comments; + const strokes = snapshot ? snapshot.strokes : annotState.strokes; + if (comments.length === 0 && strokes.length === 0) return null; + const wrap = document.createElement('div'); + Object.assign(wrap.style, { + position: 'absolute', top: '0', left: '0', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', overflow: 'visible', + }); + if (strokes.length > 0) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height); + Object.assign(svg.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', overflow: 'visible', + }); + for (const s of strokes) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('stroke', C.brand); + path.setAttribute('stroke-width', '3'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('fill', 'none'); + path.setAttribute('d', pointsToPath(s.points)); + svg.appendChild(path); + } + wrap.appendChild(svg); + } + for (const c of comments) { + // idx=-1 means non-interactive; pointerEvents stay off in the clone + wrap.appendChild(buildPinElement(c, -1)); + } + return wrap; } // --------------------------------------------------------------------------- @@ -416,8 +901,10 @@ fontSize: '11px', color: C.ash, whiteSpace: 'nowrap', marginLeft: 'auto', }); + // Variants currently arrive atomically in a single file edit, so a + // per-variant counter would lie. Say what's true. status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + (arrivedVariants + 1) + ' of ' + expectedVariants + '...' + ? 'Generating ' + expectedVariants + ' variants...' : 'Done'; row.appendChild(status); @@ -838,6 +1325,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + hideShaderOverlay(); updateBarContent('cycling'); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -860,6 +1348,10 @@ positionBar(); showHighlight(selectedElement); } + if (annotActive) positionAnnotOverlay(selectedElement); + // Shader overlay (via debug P toggle or generation) is repositioned + // by its own branch below; debug no longer has a separate overlay. + if (shaderState) positionShaderOverlay(); scrollRaf = requestAnimationFrame(tick); } scrollRaf = requestAnimationFrame(tick); @@ -937,6 +1429,8 @@ } hideBar(); hideHighlight(); + hideShaderOverlay(); + hideAnnotOverlay(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } clearSession(); @@ -976,6 +1470,8 @@ if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { hideBar(); stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -989,15 +1485,19 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); } function handleKeyDown(e) { + // When the annotation input is focused, let it handle its own keys. + if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); state = 'PICKING'; return; } + if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { hideHighlight(); state = 'IDLE'; return; } @@ -1024,6 +1524,8 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); return; @@ -1035,6 +1537,8 @@ } else { // CONFIGURING: re-select the new element and refresh the bar selectedElement = next; + clearAnnotations(); + showAnnotOverlay(next); showBar('configure'); startScrollTracking(); } @@ -1056,25 +1560,408 @@ const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; + // Commit any pending pin edit BEFORE we snapshot annotations. + if (annotEditing) finalizeEditingPin(); + currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; - sendEvent({ + // Flip to GENERATING immediately so the bar morphs without waiting on + // capture + upload. The event is emitted from captureAndEmit() once the + // screenshot is uploaded (or capture fails — we still emit, just without + // screenshotPath). + const elForCapture = selectedElement; + const captureRect = elForCapture.getBoundingClientRect(); + const snapshot = { + comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), + strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })), + }; + const basePayload = { type: 'generate', id: currentSessionId, action: selectedAction, freeformPrompt: prompt || undefined, count: selectedCount, pageUrl: location.pathname, - element: extractContext(selectedElement), - }); + element: extractContext(elForCapture), + }; + if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments; + if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes; + + // Hide the interactive overlay so it doesn't linger during generation. + hideAnnotOverlay(); + clearAnnotations(); state = 'GENERATING'; showBar('generating'); saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + + captureAndEmit(elForCapture, basePayload, snapshot, captureRect); + } + + // --------------------------------------------------------------------------- + // Screenshot capture + upload + // --------------------------------------------------------------------------- + + let msLoadPromise = null; + function loadModernScreenshot() { + if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot); + if (msLoadPromise) return msLoadPromise; + msLoadPromise = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; + s.onload = () => resolve(window.modernScreenshot); + s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; + document.head.appendChild(s); + }); + return msLoadPromise; + } + + // Collect @font-face rules from every stylesheet on the page. Cross-origin + // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules + // access, so modern-screenshot can't embed them on its own — the resulting + // SVG falls back to system fonts and text re-wraps + renders with different + // weight. We fetch the raw CSS text (CORS-permitted for these providers), + // extract @font-face blocks, inline the referenced font files as base64 + // data URIs (SVGs rasterized via canvas can't fetch external resources, + // so URLs inside the SVG silently fail without this), and pass the result + // to modern-screenshot as font.cssText. + const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i; + const FONT_MIME = { + woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject', + }; + function bufferToBase64(buf) { + const bytes = new Uint8Array(buf); + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); + } + async function inlineFontUrls(cssText) { + const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g; + const urls = new Set(); + let m; + while ((m = urlRe.exec(cssText))) { + if (FONT_EXT_RE.test(m[2])) urls.add(m[2]); + } + const map = new Map(); + await Promise.all([...urls].map(async (url) => { + try { + const res = await fetch(url); + if (!res.ok) return; + const buf = await res.arrayBuffer(); + const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2'; + const mime = FONT_MIME[ext] || 'application/octet-stream'; + map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf)); + } catch { /* skip; fall through to URL */ } + })); + return cssText.replace(urlRe, (orig, q, url) => { + const data = map.get(url); + return data ? 'url(' + q + data + q + ')' : orig; + }); + } + async function collectFontCssText() { + const chunks = []; + const fontFaceRe = /@font-face\s*\{[^}]*\}/g; + for (const sheet of document.styleSheets) { + try { + const rules = sheet.cssRules; + for (const rule of rules) { + if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) { + chunks.push(rule.cssText); + } + } + } catch { + if (!sheet.href) continue; + try { + const res = await fetch(sheet.href); + if (!res.ok) continue; + const text = await res.text(); + let m2; + while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]); + } catch { /* ignore; capture is best-effort */ } + } + } + if (chunks.length === 0) return ''; + return inlineFontUrls(chunks.join('\n')); + } + + // Capture the element (with current annotations baked in) and return a PNG + // Blob. Shared between the Go flow (uploads it to the server) and the + // debug toggle (displays it as an overlay for side-by-side comparison). + async function captureElementToBlob(el, snapshot, rect) { + try { if (document.fonts?.ready) await document.fonts.ready; } catch {} + const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); + let annotNode = null; + let savedPosition = null; + if (hasAnnotations) { + const pos = getComputedStyle(el).position; + if (pos === 'static') { + savedPosition = el.style.position; + el.style.position = 'relative'; + } + annotNode = buildAnnotationsForCapture(rect, snapshot); + el.appendChild(annotNode); + } + try { + const ms = await loadModernScreenshot(); + const fontCssText = await collectFontCssText(); + return await ms.domToBlob(el, { + scale: Math.min(window.devicePixelRatio || 1, 2), + backgroundColor: getComputedStyle(document.body).backgroundColor || '#ffffff', + font: fontCssText ? { cssText: fontCssText } : undefined, + }); + } finally { + if (annotNode) annotNode.remove(); + if (savedPosition !== null) el.style.position = savedPosition; + } + } + + async function captureAndEmit(el, basePayload, snapshot, rect) { + let screenshotPath; + let blob; + try { + blob = await captureElementToBlob(el, snapshot, rect); + } catch (err) { + console.warn('[impeccable] capture failed, proceeding without screenshot:', err); + } + // Light up the shader overlay the moment capture is ready — no reason to + // wait for the upload to complete before the user sees something alive. + if (blob && state === 'GENERATING') { + showShaderOverlay(el, blob, rect); + } + if (blob) { + try { + const uploadRes = await fetch( + 'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) + + '&eventId=' + encodeURIComponent(basePayload.id), + { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob }, + ); + if (uploadRes.ok) { + const { path: p } = await uploadRes.json(); + screenshotPath = p; + } else { + console.warn('[impeccable] annotation upload failed:', uploadRes.status); + } + } catch (err) { + console.warn('[impeccable] annotation upload failed:', err); + } + } + sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); + } + + // --------------------------------------------------------------------------- + // Shader overlay — renders the captured screenshot as a WebGL texture and + // runs an editorial "ink-wash" fragment shader over it during generation. + // A single rolling band sweeps top-to-bottom, desaturating + tinting magenta + // and leaving a soft trail. Makes the wait feel like a letterpress scan + // instead of a dead spinner. + // --------------------------------------------------------------------------- + + const SHADER_VS = `attribute vec2 a_position; +attribute vec2 a_uv; +varying vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_position, 0.0, 1.0); +}`; + + const SHADER_FS = `precision highp float; +uniform sampler2D u_texture; +uniform float u_time; +uniform vec2 u_resolution; +uniform vec3 u_accent; +varying vec2 v_uv; + +// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at +// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean +// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below" +// failure that reversed-edge smoothstep would give). +float bandAt(float d, float leadW, float trailW) { + float above = smoothstep(-leadW, 0.0, d); + float below = 1.0 - smoothstep(0.0, trailW, d); + return above * below; +} + +void main() { + vec2 uv = v_uv; + // Roller sweeps top-to-bottom with small overshoot so each cycle enters + // and exits the element cleanly. + float phase = fract(u_time / 3.4); + float y = phase * 1.25 - 0.12; + float band = bandAt(uv.y - y, 0.05, 0.32); + + // Halftone cell grid (fixed ~10 px pitch). + float cellPx = 10.0; + vec2 gridUv = uv * u_resolution / cellPx; + vec2 cellId = floor(gridUv); + vec2 cellUv = fract(gridUv) - 0.5; + vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution; + vec3 cellImg = texture2D(u_texture, sampleCenter).rgb; + float luma = dot(cellImg, vec3(0.299, 0.587, 0.114)); + // Darker cells → bigger magenta dots (classic risograph halftone curve). + float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56; + float dotMask = smoothstep(radius + 0.06, radius, length(cellUv)); + vec3 paper = vec3(0.975, 0.965, 0.955); + vec3 dotLayer = mix(paper, u_accent, dotMask); + + // Blend the halftone layer in where the roller is passing; leave the + // element pristine elsewhere. + vec3 base = texture2D(u_texture, uv).rgb; + gl_FragColor = vec4(mix(base, dotLayer, band), 1.0); +}`; + + // Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350)) + const SHADER_ACCENT = [0.82, 0.16, 0.47]; + let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + + function compileShader(gl, type, source) { + const sh = gl.createShader(type); + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(sh); + gl.deleteShader(sh); + throw new Error('shader compile failed: ' + info); + } + return sh; + } + + function positionShaderOverlay() { + if (!shaderState || !selectedElement) return; + const r = selectedElement.getBoundingClientRect(); + Object.assign(shaderState.canvas.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + } + + function hideShaderOverlay() { + if (!shaderState) return; + if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); + if (shaderState.canvas) shaderState.canvas.remove(); + const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + shaderState = null; + } + + async function showShaderOverlay(el, blob, rect) { + hideShaderOverlay(); + if (!blob || !el) return; + const canvas = document.createElement('canvas'); + canvas.id = PREFIX + '-shader'; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + Object.assign(canvas.style, { + position: 'fixed', + top: rect.top + 'px', left: rect.left + 'px', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', + zIndex: Z.bar - 1, + }); + document.body.appendChild(canvas); + + const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) + || canvas.getContext('experimental-webgl'); + if (!gl) { + // WebGL unavailable — fall back to a plain overlay so the user + // still sees something meaningful during generation. + canvas.remove(); + const img = document.createElement('img'); + img.src = URL.createObjectURL(blob); + img.id = PREFIX + '-shader'; + Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' }); + document.body.appendChild(img); + shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 }; + return; + } + + let program, texture; + try { + const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS); + program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error('program link failed: ' + gl.getProgramInfoLog(program)); + } + // Full-screen quad + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + -1, -1, 0, 1, + 1, -1, 1, 1, + -1, 1, 0, 0, + -1, 1, 0, 0, + 1, -1, 1, 1, + 1, 1, 1, 0, + ]), gl.STATIC_DRAW); + const posLoc = gl.getAttribLocation(program, 'a_position'); + const uvLoc = gl.getAttribLocation(program, 'a_uv'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(uvLoc); + gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8); + } catch (err) { + console.warn('[impeccable] shader setup failed:', err); + canvas.remove(); + return; + } + + // Upload the screenshot as a texture + let bitmap; + try { + bitmap = await createImageBitmap(blob); + } catch { + // Safari fallback: go via a regular Image + const imgUrl = URL.createObjectURL(blob); + const img = new Image(); + img.src = imgUrl; + await new Promise((r, rej) => { img.onload = r; img.onerror = rej; }); + bitmap = img; + URL.revokeObjectURL(imgUrl); + } + texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + if (bitmap.close) bitmap.close(); + + const uTime = gl.getUniformLocation(program, 'u_time'); + const uRes = gl.getUniformLocation(program, 'u_resolution'); + const uAccent = gl.getUniformLocation(program, 'u_accent'); + const uTex = gl.getUniformLocation(program, 'u_texture'); + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; + function frame() { + if (!shaderState) return; + const elapsed = (performance.now() - shaderState.startTime) / 1000; + const t = shaderState.reduced ? 0.0 : elapsed; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.useProgram(program); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.uniform1i(uTex, 0); + gl.uniform1f(uTime, t); + gl.uniform2f(uRes, canvas.width, canvas.height); + gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]); + gl.drawArrays(gl.TRIANGLES, 0, 6); + shaderState.rafId = requestAnimationFrame(frame); + } + frame(); } function handleAccept() { @@ -2713,6 +3600,7 @@ function init() { initHighlight(); + initAnnotOverlay(); initBar(); initActionPicker(); initGlobalBar(); diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs index 109aa768e..97163b255 100644 --- a/.trae-cn/skills/impeccable/scripts/live-server.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs @@ -18,7 +18,6 @@ import { randomUUID } from 'node:crypto'; import { spawn, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; @@ -56,8 +55,13 @@ const state = { pendingEvents: [], // browser events waiting for agent poll pendingPolls: [], // agent poll callbacks waiting for browser events exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots }; +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + function enqueueEvent(event) { if (state.pendingPolls.length > 0) { state.pendingPolls.shift()(event); @@ -134,6 +138,10 @@ function validateEvent(msg) { if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; return null; case 'accept': if (!msg.id) return 'accept: missing id'; @@ -175,6 +183,83 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { return; } + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + // --- Health --- if (p === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -250,10 +335,11 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } const absPath = path.resolve(process.cwd(), filePath); if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } - try { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(fs.readFileSync(absPath, 'utf-8')); - } catch { res.writeHead(404); res.end('File not found'); } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); return; } @@ -411,6 +497,9 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } for (const res of state.sseClients) { try { res.end(); } catch {} } state.sseClients.clear(); for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); @@ -442,12 +531,14 @@ Options: --help Show this help Endpoints: - /live.js Browser script (element picker + variant cycling) - /detect.js Detection overlay (backwards compatible) - /events SSE stream (server→browser) + POST (browser→server) - /poll Long-poll for agent CLI - /source Raw source file reader (no-HMR fallback) - /health Health check`); + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); process.exit(0); } @@ -531,6 +622,12 @@ try { state.token = randomUUID(); const portArg = args.find(a => a.startsWith('--port=')); state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); const { detectScript, liveScript } = loadBrowserScripts(); const liveScriptWithToken = diff --git a/.trae-cn/skills/impeccable/scripts/modern-screenshot.umd.js b/.trae-cn/skills/impeccable/scripts/modern-screenshot.umd.js new file mode 100644 index 000000000..a9c5208f6 --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/modern-screenshot.umd.js @@ -0,0 +1,14 @@ +(function(y,v){typeof exports=="object"&&typeof module!="undefined"?v(exports):typeof define=="function"&&define.amd?define(["exports"],v):(y=typeof globalThis!="undefined"?globalThis:y||self,v(y.modernScreenshot={}))})(this,function(y){"use strict";var rr=Object.defineProperty,nr=Object.defineProperties;var or=Object.getOwnPropertyDescriptors;var Z=Object.getOwnPropertySymbols;var xe=Object.prototype.hasOwnProperty,Me=Object.prototype.propertyIsEnumerable;var Oe=Math.pow,Le=(y,v,N)=>v in y?rr(y,v,{enumerable:!0,configurable:!0,writable:!0,value:N}):y[v]=N,D=(y,v)=>{for(var N in v||(v={}))xe.call(v,N)&&Le(y,N,v[N]);if(Z)for(var N of Z(v))Me.call(v,N)&&Le(y,N,v[N]);return y},M=(y,v)=>nr(y,or(v));var je=(y,v)=>{var N={};for(var R in y)xe.call(y,R)&&v.indexOf(R)<0&&(N[R]=y[R]);if(y!=null&&Z)for(var R of Z(y))v.indexOf(R)<0&&Me.call(y,R)&&(N[R]=y[R]);return N};var C=(y,v,N)=>new Promise((R,O)=>{var X=P=>{try{q(N.next(P))}catch(W){O(W)}},j=P=>{try{q(N.throw(P))}catch(W){O(W)}},q=P=>P.done?R(P.value):Promise.resolve(P.value).then(X,j);q((N=N.apply(y,v)).next())});var Be;function v(e,t){return e[13]=1,e[14]=t>>8,e[15]=t&255,e[16]=t>>8,e[17]=t&255,e}const N=112,R=72,O=89,X=115;let j;function q(){const e=new Int32Array(256);for(let t=0;t<256;t++){let r=t;for(let n=0;n<8;n++)r=r&1?3988292384^r>>>1:r>>>1;e[t]=r}return e}function P(e){let t=-1;j||(j=q());for(let r=0;r>>8;return t^-1}function W(e){const t=e.length-1;for(let r=t;r>=4;r--)if(e[r-4]===9&&e[r-3]===N&&e[r-2]===R&&e[r-1]===O&&e[r]===X)return r-3;return 0}function ae(e,t,r=!1){const n=new Uint8Array(13);t*=39.3701,n[0]=N,n[1]=R,n[2]=O,n[3]=X,n[4]=t>>>24,n[5]=t>>>16,n[6]=t>>>8,n[7]=t&255,n[8]=n[4],n[9]=n[5],n[10]=n[6],n[11]=n[7],n[12]=1;const i=P(n),a=new Uint8Array(4);if(a[0]=i>>>24,a[1]=i>>>16,a[2]=i>>>8,a[3]=i&255,r){const s=W(e);return e.set(n,s),e.set(a,s+13),e}else{const s=new Uint8Array(4);s[0]=0,s[1]=0,s[2]=0,s[3]=9;const o=new Uint8Array(54);return o.set(e,0),o.set(s,33),o.set(n,37),o.set(a,50),o}}const qe="AAlwSFlz",We="AAAJcEhZ",He="AAAACXBI";function Ve(e){let t=e.indexOf(qe);return t===-1&&(t=e.indexOf(We)),t===-1&&(t=e.indexOf(He)),t}const se="[modern-screenshot]",U=typeof window!="undefined",ze=U&&"Worker"in window,ie=U&&"atob"in window,Xe=U&&"btoa"in window,ee=U?(Be=window.navigator)==null?void 0:Be.userAgent:"",le=ee.includes("Chrome"),G=ee.includes("AppleWebKit")&&!le,te=ee.includes("Firefox"),Ge=e=>e&&"__CONTEXT__"in e,Ye=e=>e.constructor.name==="CSSFontFaceRule",Je=e=>e.constructor.name==="CSSImportRule",Ke=e=>e.constructor.name==="CSSLayerBlockRule",I=e=>e.nodeType===1,H=e=>typeof e.className=="object",ce=e=>e.tagName==="image",Qe=e=>e.tagName==="use",V=e=>I(e)&&typeof e.style!="undefined"&&!H(e),Ze=e=>e.nodeType===8,et=e=>e.nodeType===3,$=e=>e.tagName==="IMG",Y=e=>e.tagName==="VIDEO",tt=e=>e.tagName==="CANVAS",rt=e=>e.tagName==="TEXTAREA",nt=e=>e.tagName==="INPUT",ot=e=>e.tagName==="STYLE",at=e=>e.tagName==="SCRIPT",st=e=>e.tagName==="SELECT",it=e=>e.tagName==="SLOT",lt=e=>e.tagName==="IFRAME",ct=(...e)=>console.warn(se,...e);function ut(e){var r;const t=(r=e==null?void 0:e.createElement)==null?void 0:r.call(e,"canvas");return t&&(t.height=t.width=1),!!t&&"toDataURL"in t&&!!t.toDataURL("image/webp").includes("image/webp")}const re=e=>e.startsWith("data:");function ue(e,t){if(e.match(/^[a-z]+:\/\//i))return e;if(U&&e.match(/^\/\//))return window.location.protocol+e;if(e.match(/^[a-z]+:/i)||!U)return e;const r=J().implementation.createHTMLDocument(),n=r.createElement("base"),i=r.createElement("a");return r.head.appendChild(n),r.body.appendChild(i),t&&(n.href=t),i.href=e,i.href}function J(e){var t;return(t=e&&I(e)?e==null?void 0:e.ownerDocument:e)!=null?t:window.document}const K="http://www.w3.org/2000/svg";function fe(e,t,r){const n=J(r).createElementNS(K,"svg");return n.setAttributeNS(null,"width",e.toString()),n.setAttributeNS(null,"height",t.toString()),n.setAttributeNS(null,"viewBox",`0 0 ${e} ${t}`),n}function de(e,t){let r=new XMLSerializer().serializeToString(e);return t&&(r=r.replace(/[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu,"")),`data:image/svg+xml;charset=utf-8,${encodeURIComponent(r)}`}function ft(e,t="image/png",r=1){return C(this,null,function*(){try{return yield new Promise((n,i)=>{e.toBlob(a=>{a?n(a):i(new Error("Blob is null"))},t,r)})}catch(n){if(ie)return dt(e.toDataURL(t,r));throw n}})}function dt(e){var o,c;const[t,r]=e.split(","),n=(c=(o=t.match(/data:(.+);/))==null?void 0:o[1])!=null?c:void 0,i=window.atob(r),a=i.length,s=new Uint8Array(a);for(let u=0;u{const i=new FileReader;i.onload=()=>r(i.result),i.onerror=()=>n(i.error),i.onabort=()=>n(new Error(`Failed read blob to ${t}`)),t==="dataUrl"?i.readAsDataURL(e):t==="arrayBuffer"&&i.readAsArrayBuffer(e)})}const gt=e=>ge(e,"dataUrl"),mt=e=>ge(e,"arrayBuffer");function _(e,t){const r=J(t).createElement("img");return r.decoding="sync",r.loading="eager",r.src=e,r}function L(e,t){return new Promise(r=>{const{timeout:n,ownerDocument:i,onError:a,onWarn:s}=t!=null?t:{},o=typeof e=="string"?_(e,J(i)):e;let c=null,u=null;function l(){r(o),c&&clearTimeout(c),u==null||u()}if(n&&(c=setTimeout(l,n)),Y(o)){const d=o.currentSrc||o.src;if(!d)return o.poster?L(o.poster,t).then(r):l();if(o.readyState>=2)return l();const m=l,f=h=>{s==null||s("Failed video load",d,h),a==null||a(h),l()};u=()=>{o.removeEventListener("loadeddata",m),o.removeEventListener("error",f)},o.addEventListener("loadeddata",m,{once:!0}),o.addEventListener("error",f,{once:!0})}else{const d=ce(o)?o.href.baseVal:o.currentSrc||o.src;if(!d)return l();const m=()=>C(this,null,function*(){if($(o)&&"decode"in o)try{yield o.decode()}catch(h){s==null||s("Failed to decode image, trying to render anyway",o.dataset.originalSrc||d,h)}l()}),f=h=>{s==null||s("Failed image load",o.dataset.originalSrc||d,h),l()};if($(o)&&o.complete)return m();u=()=>{o.removeEventListener("load",m),o.removeEventListener("error",f)},o.addEventListener("load",m,{once:!0}),o.addEventListener("error",f,{once:!0})}})}function me(e,t){return C(this,null,function*(){V(e)&&($(e)||Y(e)?yield L(e,t):yield Promise.all(["img","video"].flatMap(r=>Array.from(e.querySelectorAll(r)).map(n=>L(n,t)))))})}const he=function(){let t=0;const r=()=>`0000${(Math.random()*Oe(36,4)<<0).toString(36)}`.slice(-4);return()=>(t+=1,`u${r()}${t}`)}();function we(e){return e==null?void 0:e.split(",").map(t=>t.trim().replace(/"|'/g,"").toLowerCase()).filter(Boolean)}let pe=0;function ht(e){const t=`${se}[#${pe}]`;return pe++,{time:r=>e&&console.time(`${t} ${r}`),timeEnd:r=>e&&console.timeEnd(`${t} ${r}`),warn:(...r)=>e&&ct(...r)}}function wt(e){return{cache:e?"no-cache":"force-cache"}}function k(e,t){return C(this,null,function*(){return Ge(e)?e:ye(e,M(D({},t),{autoDestruct:!0}))})}function ye(e,t){return C(this,null,function*(){var f,h,g,p,E;const{scale:r=1,workerUrl:n,workerNumber:i=1}=t||{},a=!!(t!=null&&t.debug),s=(f=t==null?void 0:t.features)!=null?f:!0,o=(h=e.ownerDocument)!=null?h:U?window.document:void 0,c=(p=(g=e.ownerDocument)==null?void 0:g.defaultView)!=null?p:U?window:void 0,u=new Map,l=M(D({width:0,height:0,quality:1,type:"image/png",scale:r,backgroundColor:null,style:null,filter:null,maximumCanvasSize:0,timeout:3e4,progress:null,debug:a,fetch:D({requestInit:wt((E=t==null?void 0:t.fetch)==null?void 0:E.bypassingCache),placeholderImage:"data:image/png;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",bypassingCache:!1},t==null?void 0:t.fetch),fetchFn:null,font:{},drawImageInterval:100,workerUrl:null,workerNumber:i,onCloneEachNode:null,onCloneNode:null,onEmbedNode:null,onCreateForeignObjectSvg:null,includeStyleProperties:null,autoDestruct:!1},t),{__CONTEXT__:!0,log:ht(a),node:e,ownerDocument:o,ownerWindow:c,dpi:r===1?null:96*r,svgStyleElement:be(o),svgDefsElement:o==null?void 0:o.createElementNS(K,"defs"),svgStyles:new Map,defaultComputedStyles:new Map,workers:[...Array.from({length:ze&&n&&i?i:0})].map(()=>{try{const b=new Worker(n);return b.onmessage=w=>C(this,null,function*(){var A,F,B,$e;const{url:S,result:T}=w.data;T?(F=(A=u.get(S))==null?void 0:A.resolve)==null||F.call(A,T):($e=(B=u.get(S))==null?void 0:B.reject)==null||$e.call(B,new Error(`Error receiving message from worker: ${S}`))}),b.onmessageerror=w=>{var T,A;const{url:S}=w.data;(A=(T=u.get(S))==null?void 0:T.reject)==null||A.call(T,new Error(`Error receiving message from worker: ${S}`))},b}catch(b){return l.log.warn("Failed to new Worker",b),null}}).filter(Boolean),fontFamilies:new Map,fontCssTexts:new Map,acceptOfImage:`${[ut(o)&&"image/webp","image/svg+xml","image/*","*/*"].filter(Boolean).join(",")};q=0.8`,requests:u,drawImageCount:0,tasks:[],features:s,isEnable:b=>{var w,S;return b==="restoreScrollPosition"?typeof s=="boolean"?!1:(w=s[b])!=null?w:!1:typeof s=="boolean"?s:(S=s[b])!=null?S:!0},shadowRoots:[]});l.log.time("wait until load"),yield me(e,{timeout:l.timeout,onWarn:l.log.warn}),l.log.timeEnd("wait until load");const{width:d,height:m}=pt(e,l);return l.width=d,l.height=m,l})}function be(e){if(!e)return;const t=e.createElement("style"),r=t.ownerDocument.createTextNode(` +.______background-clip--text { + background-clip: text; + -webkit-background-clip: text; +} +`);return t.appendChild(r),t}function pt(e,t){let{width:r,height:n}=t;if(I(e)&&(!r||!n)){const i=e.getBoundingClientRect();r=r||i.width||Number(e.getAttribute("width"))||0,n=n||i.height||Number(e.getAttribute("height"))||0}return{width:r,height:n}}function yt(e,t){return C(this,null,function*(){const{log:r,timeout:n,drawImageCount:i,drawImageInterval:a}=t;r.time("image to canvas");const s=yield L(e,{timeout:n,onWarn:t.log.warn}),{canvas:o,context2d:c}=bt(e.ownerDocument,t),u=()=>{try{c==null||c.drawImage(s,0,0,o.width,o.height)}catch(l){t.log.warn("Failed to drawImage",l)}};if(u(),t.isEnable("fixSvgXmlDecode"))for(let l=0;l{setTimeout(()=>{c==null||c.clearRect(0,0,o.width,o.height),u(),d()},l+a)});return t.drawImageCount=0,r.timeEnd("image to canvas"),o})}function bt(e,t){const{width:r,height:n,scale:i,backgroundColor:a,maximumCanvasSize:s}=t,o=e.createElement("canvas");o.width=Math.floor(r*i),o.height=Math.floor(n*i),o.style.width=`${r}px`,o.style.height=`${n}px`,s&&(o.width>s||o.height>s)&&(o.width>s&&o.height>s?o.width>o.height?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s):o.width>s?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s));const c=o.getContext("2d");return c&&a&&(c.fillStyle=a,c.fillRect(0,0,o.width,o.height)),{canvas:o,context2d:c}}function Se(e,t){if(e.ownerDocument)try{const a=e.toDataURL();if(a!=="data:,")return _(a,e.ownerDocument)}catch(a){t.log.warn("Failed to clone canvas",a)}const r=e.cloneNode(!1),n=e.getContext("2d"),i=r.getContext("2d");try{return n&&i&&i.putImageData(n.getImageData(0,0,e.width,e.height),0,0),r}catch(a){t.log.warn("Failed to clone canvas",a)}return r}function St(e,t){var r;try{if((r=e==null?void 0:e.contentDocument)!=null&&r.documentElement)return ne(e.contentDocument.documentElement,t)}catch(n){t.log.warn("Failed to clone iframe",n)}return e.cloneNode(!1)}function Et(e){const t=e.cloneNode(!1);return e.currentSrc&&e.currentSrc!==e.src&&(t.src=e.currentSrc,t.srcset=""),t.loading==="lazy"&&(t.loading="eager"),t}function Ct(e,t){return C(this,null,function*(){if(e.ownerDocument&&!e.currentSrc&&e.poster)return _(e.poster,e.ownerDocument);const r=e.cloneNode(!1);r.crossOrigin="anonymous",e.currentSrc&&e.currentSrc!==e.src&&(r.src=e.currentSrc);const n=r.ownerDocument;if(n){let i=!0;if(yield L(r,{onError:()=>i=!1,onWarn:t.log.warn}),!i)return e.poster?_(e.poster,e.ownerDocument):r;r.currentTime=e.currentTime,yield new Promise(s=>{r.addEventListener("seeked",s,{once:!0})});const a=n.createElement("canvas");a.width=e.offsetWidth,a.height=e.offsetHeight;try{const s=a.getContext("2d");s&&s.drawImage(r,0,0,a.width,a.height)}catch(s){return t.log.warn("Failed to clone video",s),e.poster?_(e.poster,e.ownerDocument):r}return Se(a,t)}return r})}function Tt(e,t){return tt(e)?Se(e,t):lt(e)?St(e,t):$(e)?Et(e):Y(e)?Ct(e,t):e.cloneNode(!1)}function vt(e){let t=e.sandbox;if(!t){const{ownerDocument:r}=e;try{r&&(t=r.createElement("iframe"),t.id=`__SANDBOX__${he()}`,t.width="0",t.height="0",t.style.visibility="hidden",t.style.position="fixed",r.body.appendChild(t),t.srcdoc='',e.sandbox=t)}catch(n){e.log.warn("Failed to getSandBox",n)}}return t}const At=["width","height","-webkit-text-fill-color"],Nt=["stroke","fill"];function Ee(e,t,r){const{defaultComputedStyles:n}=r,i=e.nodeName.toLowerCase(),a=H(e)&&i!=="svg",s=a?Nt.map(g=>[g,e.getAttribute(g)]).filter(([,g])=>g!==null):[],o=[a&&"svg",i,s.map((g,p)=>`${g}=${p}`).join(","),t].filter(Boolean).join(":");if(n.has(o))return n.get(o);const c=vt(r),u=c==null?void 0:c.contentWindow;if(!u)return new Map;const l=u==null?void 0:u.document;let d,m;a?(d=l.createElementNS(K,"svg"),m=d.ownerDocument.createElementNS(d.namespaceURI,i),s.forEach(([g,p])=>{m.setAttributeNS(null,g,p)}),d.appendChild(m)):d=m=l.createElement(i),m.textContent=" ",l.body.appendChild(d);const f=u.getComputedStyle(m,t),h=new Map;for(let g=f.length,p=0;pn.set(d,l));function s(c){const u=e.getPropertyValue(c),l=e.getPropertyPriority(c),d=c.lastIndexOf("-"),m=d>-1?c.substring(0,d):void 0;if(m){let f=a.get(m);f||(f=new Map,a.set(m,f)),f.set(c,[u,l])}t.get(c)===u&&!l||(m?i.push(m):n.set(c,[u,l]))}return n}function Rt(e,t,r,n){var d,m,f,h;const{ownerWindow:i,includeStyleProperties:a,currentParentNodeStyle:s}=n,o=t.style,c=i.getComputedStyle(e),u=Ee(e,null,n);s==null||s.forEach((g,p)=>{u.delete(p)});const l=Ce(c,u,a);l.delete("transition-property"),l.delete("all"),l.delete("d"),l.delete("content"),r&&(l.delete("position"),l.delete("margin-top"),l.delete("margin-right"),l.delete("margin-bottom"),l.delete("margin-left"),l.delete("margin-block-start"),l.delete("margin-block-end"),l.delete("margin-inline-start"),l.delete("margin-inline-end"),l.set("box-sizing",["border-box",""])),((d=l.get("background-clip"))==null?void 0:d[0])==="text"&&t.classList.add("______background-clip--text"),le&&(l.has("font-kerning")||l.set("font-kerning",["normal",""]),(((m=l.get("overflow-x"))==null?void 0:m[0])==="hidden"||((f=l.get("overflow-y"))==null?void 0:f[0])==="hidden")&&((h=l.get("text-overflow"))==null?void 0:h[0])==="ellipsis"&&e.scrollWidth===e.clientWidth&&l.set("text-overflow",["clip",""]));for(let g=o.length,p=0;p{o.setProperty(E,g,p)}),l}function It(e,t){(rt(e)||nt(e)||st(e))&&t.setAttribute("value",e.value)}const kt=["::before","::after"],Dt=["::-webkit-scrollbar","::-webkit-scrollbar-button","::-webkit-scrollbar-thumb","::-webkit-scrollbar-track","::-webkit-scrollbar-track-piece","::-webkit-scrollbar-corner","::-webkit-resizer"];function Pt(e,t,r,n,i){const{ownerWindow:a,svgStyleElement:s,svgStyles:o,currentNodeStyle:c}=n;if(!s||!a)return;function u(l){var w;const d=a.getComputedStyle(e,l);let m=d.getPropertyValue("content");if(!m||m==="none")return;i==null||i(m),m=m.replace(/(')|(")|(counter\(.+\))/g,"");const f=[he()],h=Ee(e,l,n);c==null||c.forEach((S,T)=>{h.delete(T)});const g=Ce(d,h,n.includeStyleProperties);g.delete("content"),g.delete("-webkit-locale"),((w=g.get("background-clip"))==null?void 0:w[0])==="text"&&t.classList.add("______background-clip--text");const p=[`content: '${m}';`];if(g.forEach(([S,T],A)=>{p.push(`${A}: ${S}${T?" !important":""};`)}),p.length===1)return;try{t.className=[t.className,...f].join(" ")}catch(S){n.log.warn("Failed to copyPseudoClass",S);return}const E=p.join(` + `);let b=o.get(E);b||(b=[],o.set(E,b)),b.push(`.${f[0]}${l}`)}kt.forEach(u),r&&Dt.forEach(u)}const Te=new Set(["symbol"]);function ve(e,t,r,n,i){return C(this,null,function*(){if(I(r)&&(ot(r)||at(r))||n.filter&&!n.filter(r))return;Te.has(t.nodeName)||Te.has(r.nodeName)?n.currentParentNodeStyle=void 0:n.currentParentNodeStyle=n.currentNodeStyle;const a=yield ne(r,n,!1,i);n.isEnable("restoreScrollPosition")&&Ut(e,a),t.appendChild(a)})}function Ae(e,t,r,n){return C(this,null,function*(){var a;let i=e.firstChild;I(e)&&e.shadowRoot&&(i=(a=e.shadowRoot)==null?void 0:a.firstChild,r.shadowRoots.push(e.shadowRoot));for(let s=i;s;s=s.nextSibling)if(!Ze(s))if(I(s)&&it(s)&&typeof s.assignedNodes=="function"){const o=s.assignedNodes();for(let c=0;ce.clientHeight||e.scrollWidth>e.clientWidth)}const p=(d=h.get("text-transform"))==null?void 0:d[0],E=we((m=h.get("font-family"))==null?void 0:m[0]),b=E?w=>{p==="uppercase"?w=w.toUpperCase():p==="lowercase"?w=w.toLowerCase():p==="capitalize"&&(w=w[0].toUpperCase()+w.substring(1)),E.forEach(S=>{let T=s.get(S);T||s.set(S,T=new Set),w.split("").forEach(A=>T.add(A))})}:void 0;return Pt(e,f,g,t,b),It(e,f),Y(e)||(yield Ae(e,f,t,b)),yield o==null?void 0:o(f),f}const c=e.cloneNode(!1);return yield Ae(e,c,t),yield o==null?void 0:o(c),c})}function Ne(e){if(e.ownerDocument=void 0,e.ownerWindow=void 0,e.svgStyleElement=void 0,e.svgDefsElement=void 0,e.svgStyles.clear(),e.defaultComputedStyles.clear(),e.sandbox){try{e.sandbox.remove()}catch(t){e.log.warn("Failed to destroyContext",t)}e.sandbox=void 0}e.workers=[],e.fontFamilies.clear(),e.fontCssTexts.clear(),e.requests.clear(),e.tasks=[],e.shadowRoots=[]}function Bt(e){const o=e,{url:t,timeout:r,responseType:n}=o,i=je(o,["url","timeout","responseType"]),a=new AbortController,s=r?setTimeout(()=>a.abort(),r):void 0;return fetch(t,D({signal:a.signal},i)).then(c=>{if(!c.ok)throw new Error("Failed fetch, not 2xx response",{cause:c});switch(n){case"arrayBuffer":return c.arrayBuffer();case"dataUrl":return c.blob().then(gt);case"text":default:return c.text()}}).finally(()=>clearTimeout(s))}function z(e,t){const{url:r,requestType:n="text",responseType:i="text",imageDom:a}=t;let s=r;const{timeout:o,acceptOfImage:c,requests:u,fetchFn:l,fetch:{requestInit:d,bypassingCache:m,placeholderImage:f},font:h,workers:g,fontFamilies:p}=e;n==="image"&&(G||te)&&e.drawImageCount++;let E=u.get(r);if(!E){m&&m instanceof RegExp&&m.test(s)&&(s+=(/\?/.test(s)?"&":"?")+new Date().getTime());const b=n.startsWith("font")&&h&&h.minify,w=new Set;b&&n.split(";")[1].split(",").forEach(F=>{p.has(F)&&p.get(F).forEach(B=>w.add(B))});const S=b&&w.size,T=D({url:s,timeout:o,responseType:S?"arrayBuffer":i,headers:n==="image"?{accept:c}:void 0},d);E={type:n,resolve:void 0,reject:void 0,response:null},E.response=C(this,null,function*(){if(l&&n==="image"){const A=yield l(r);if(A)return A}return!G&&r.startsWith("http")&&g.length?new Promise((A,F)=>{g[u.size&g.length-1].postMessage(D({rawUrl:r},T)),E.resolve=A,E.reject=F}):Bt(T)}).catch(A=>{if(u.delete(r),n==="image"&&f)return e.log.warn("Failed to fetch image base64, trying to use placeholder image",s),typeof f=="string"?f:f(a);throw A}),u.set(r,E)}return E.response}function Re(e,t,r,n){return C(this,null,function*(){if(!Ie(e))return e;for(const[i,a]of $t(e,t))try{const s=yield z(r,{url:a,requestType:n?"image":"text",responseType:"dataUrl"});e=e.replace(Lt(i),`$1${s}$3`)}catch(s){r.log.warn("Failed to fetch css data url",i,s)}return e})}function Ie(e){return/url\((['"]?)([^'"]+?)\1\)/.test(e)}const ke=/url\((['"]?)([^'"]+?)\1\)/g;function $t(e,t){const r=[];return e.replace(ke,(n,i,a)=>(r.push([a,ue(a,t)]),n)),r.filter(([n])=>!re(n))}function Lt(e){const t=e.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${t})(['"]?\\))`,"g")}const xt=["background-image","border-image-source","-webkit-border-image","-webkit-mask-image","list-style-image"];function Mt(e,t){return xt.map(r=>{const n=e.getPropertyValue(r);return!n||n==="none"?null:((G||te)&&t.drawImageCount++,Re(n,null,t,!0).then(i=>{!i||n===i||e.setProperty(r,i,e.getPropertyPriority(r))}))}).filter(Boolean)}function Ot(e,t){if($(e)){const r=e.currentSrc||e.src;if(!re(r))return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.srcset="",e.dataset.originalSrc=r,e.src=n||"")})];(G||te)&&t.drawImageCount++}else if(H(e)&&!re(e.href.baseVal)){const r=e.href.baseVal;return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.dataset.originalSrc=r,e.href.baseVal=n||"")})]}return[]}function jt(e,t){var o;const{ownerDocument:r,svgDefsElement:n}=t,i=(o=e.getAttribute("href"))!=null?o:e.getAttribute("xlink:href");if(!i)return[];const[a,s]=i.split("#");if(s){const c=`#${s}`,u=t.shadowRoots.reduce((l,d)=>l!=null?l:d.querySelector(`svg ${c}`),r==null?void 0:r.querySelector(`svg ${c}`));if(a&&e.setAttribute("href",c),n!=null&&n.querySelector(c))return[];if(u)return n==null||n.appendChild(u.cloneNode(!0)),[];if(a)return[z(t,{url:a,responseType:"text"}).then(l=>{n==null||n.insertAdjacentHTML("beforeend",l)})]}return[]}function De(e,t){const{tasks:r}=t;I(e)&&(($(e)||ce(e))&&r.push(...Ot(e,t)),Qe(e)&&r.push(...jt(e,t))),V(e)&&r.push(...Mt(e.style,t)),e.childNodes.forEach(n=>{De(n,t)})}function qt(e,t){return C(this,null,function*(){const{ownerDocument:r,svgStyleElement:n,fontFamilies:i,fontCssTexts:a,tasks:s,font:o}=t;if(!(!r||!n||!i.size))if(o&&o.cssText){const c=Ue(o.cssText,t);n.appendChild(r.createTextNode(`${c} +`))}else{const c=Array.from(r.styleSheets).filter(f=>{try{return"cssRules"in f&&!!f.cssRules.length}catch(h){return t.log.warn(`Error while reading CSS rules from ${f.href}`,h),!1}}),u=r.implementation.createHTMLDocument(""),l=u.createElement("style");u.head.appendChild(l);const d=l.sheet;yield Promise.all(c.flatMap(f=>Array.from(f.cssRules).map(h=>C(this,null,function*(){if(Je(h)){const g=h.href;let p="";try{p=yield z(t,{url:g,requestType:"text",responseType:"text"})}catch(b){t.log.warn(`Error fetch remote css import from ${g}`,b)}const E=p.replace(ke,(b,w,S)=>b.replace(S,ue(S,g)));for(const b of Ht(E))try{d.insertRule(b,d.cssRules.length)}catch(w){t.log.warn("Error inserting rule from remote css import",{rule:b,error:w})}}})))),d.cssRules.length&&c.push(d);const m=[];c.forEach(f=>{oe(f.cssRules,m)}),m.filter(f=>{var h;return Ye(f)&&Ie(f.style.getPropertyValue("src"))&&((h=we(f.style.getPropertyValue("font-family")))==null?void 0:h.some(g=>i.has(g)))}).forEach(f=>{const h=f,g=a.get(h.cssText);g?n.appendChild(r.createTextNode(`${g} +`)):s.push(Re(h.cssText,h.parentStyleSheet?h.parentStyleSheet.href:null,t).then(p=>{p=Ue(p,t),a.set(h.cssText,p),n.appendChild(r.createTextNode(`${p} +`))}))})}})}const Wt=/(\/\*[\s\S]*?\*\/)/g,Pe=/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi;function Ht(e){if(e==null)return[];const t=[];let r=e.replace(Wt,"");for(;;){const a=Pe.exec(r);if(!a)break;t.push(a[0])}r=r.replace(Pe,"");const n=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,i=new RegExp("((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})","gi");for(;;){let a=n.exec(r);if(a)i.lastIndex=n.lastIndex;else if(a=i.exec(r),a)n.lastIndex=i.lastIndex;else break;t.push(a[0])}return t}const Vt=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,zt=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Ue(e,t){const{font:r}=t,n=r?r==null?void 0:r.preferredFormat:void 0;return n?e.replace(zt,i=>{for(;;){const[a,,s]=Vt.exec(i)||[];if(!s)return"";if(s===n)return`src: ${a};`}}):e}function oe(e,t=[]){for(const r of Array.from(e))Ke(r)?t.push(...oe(r.cssRules)):"cssRules"in r?oe(r.cssRules,t):t.push(r);return t}const Xt=/\bx?link:?href\s*=\s*["'](?!data:)[^"']+["']/i;function Gt(e){return Xt.test(e.innerHTML)}function _e(e,t){return C(this,null,function*(){const r=yield k(e,t);if(I(r.node)&&H(r.node)&&!Gt(r.node))return r.node;const{ownerDocument:n,log:i,tasks:a,svgStyleElement:s,svgDefsElement:o,svgStyles:c,font:u,progress:l,autoDestruct:d,onCloneNode:m,onEmbedNode:f,onCreateForeignObjectSvg:h}=r;i.time("clone node");const g=yield ne(r.node,r,!0);if(s&&n){let S="";c.forEach((T,A)=>{S+=`${T.join(`, +`)} { + ${A} +} +`}),s.appendChild(n.createTextNode(S))}i.timeEnd("clone node"),yield m==null?void 0:m(g),u!==!1&&I(g)&&(i.time("embed web font"),yield qt(g,r),i.timeEnd("embed web font")),i.time("embed node"),De(g,r);const p=a.length;let E=0;const b=()=>C(this,null,function*(){for(;;){const S=a.pop();if(!S)break;try{yield S}catch(T){r.log.warn("Failed to run task",T)}l==null||l(++E,p)}});l==null||l(E,p),yield Promise.all([...Array.from({length:4})].map(b)),i.timeEnd("embed node"),yield f==null?void 0:f(g);const w=Yt(g,r);return o&&w.insertBefore(o,w.children[0]),s&&w.insertBefore(s,w.children[0]),d&&Ne(r),yield h==null?void 0:h(w),w})}function Yt(e,t){const{width:r,height:n}=t,i=fe(r,n,e.ownerDocument),a=i.ownerDocument.createElementNS(i.namespaceURI,"foreignObject");return a.setAttributeNS(null,"x","0%"),a.setAttributeNS(null,"y","0%"),a.setAttributeNS(null,"width","100%"),a.setAttributeNS(null,"height","100%"),a.append(e),i.appendChild(a),i}function Q(e,t){return C(this,null,function*(){var s;const r=yield k(e,t),n=yield _e(r),i=de(n,r.isEnable("removeControlCharacter"));r.autoDestruct||(r.svgStyleElement=be(r.ownerDocument),r.svgDefsElement=(s=r.ownerDocument)==null?void 0:s.createElementNS(K,"defs"),r.svgStyles.clear());const a=_(i,n.ownerDocument);return yield yt(a,r)})}function Jt(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,type:i,quality:a,dpi:s}=r,o=yield Q(r);n.time("canvas to blob");const c=yield ft(o,i,a);if(["image/png","image/jpeg"].includes(i)&&s){const u=yield mt(c.slice(0,33));let l=new Uint8Array(u);return i==="image/png"?l=ae(l,s):i==="image/jpeg"&&(l=v(l,s)),n.timeEnd("canvas to blob"),new Blob([l,c.slice(33)],{type:i})}return n.timeEnd("canvas to blob"),c})}function x(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,quality:i,type:a,dpi:s}=r,o=yield Q(r);n.time("canvas to data url");let c=o.toDataURL(a,i);if(["image/png","image/jpeg"].includes(a)&&s&&ie&&Xe){const[u,l]=c.split(",");let d=0,m=!1;if(a==="image/png"){const w=Ve(l);w>=0?(d=Math.ceil((w+28)/3)*4,m=!0):d=33/3*4}else a==="image/jpeg"&&(d=18/3*4);const f=l.substring(0,d),h=l.substring(d),g=window.atob(f),p=new Uint8Array(g.length);for(let w=0;w mirroring selectedElement's bounding rect. Click (no + // drag) drops a comment pin; drag paints a magenta SVG stroke. All coords + // are stored in element-local CSS px so they survive scroll / resize and + // correlate directly with the captured PNG. + // --------------------------------------------------------------------------- + + const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click + const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it + let annotOverlayEl = null; + let annotSvgEl = null; + let annotPinsEl = null; + let annotClearChipEl = null; + let annotState = { comments: [], strokes: [] }; + let annotActive = false; + // `annotPointer` is either: + // { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin + // { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin + let annotPointer = null; + let annotEditing = null; // { idx, input, wrapEl } + let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete + + function initAnnotOverlay() { + annotOverlayEl = document.createElement('div'); + annotOverlayEl.id = PREFIX + '-annot'; + Object.assign(annotOverlayEl.style, { + position: 'fixed', top: '0', left: '0', width: '0', height: '0', + pointerEvents: 'auto', zIndex: Z.highlight + 2, + display: 'none', overflow: 'visible', + cursor: 'crosshair', touchAction: 'none', + }); + + annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + annotSvgEl.id = PREFIX + '-annot-svg'; + Object.assign(annotSvgEl.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', + // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via + // pointer-events=stroke so gaps still fall through to the overlay. + pointerEvents: 'none', overflow: 'visible', + }); + annotOverlayEl.appendChild(annotSvgEl); + + annotPinsEl = document.createElement('div'); + annotPinsEl.id = PREFIX + '-annot-pins'; + Object.assign(annotPinsEl.style, { + position: 'absolute', inset: '0', + pointerEvents: 'none', + }); + annotOverlayEl.appendChild(annotPinsEl); + + annotClearChipEl = document.createElement('div'); + annotClearChipEl.id = PREFIX + '-annot-clear'; + annotClearChipEl.dataset.annotClear = 'true'; + annotClearChipEl.textContent = 'Clear'; + Object.assign(annotClearChipEl.style, { + position: 'absolute', top: '8px', right: '8px', + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '10px', fontWeight: '500', + letterSpacing: '0.08em', textTransform: 'uppercase', + padding: '5px 12px', borderRadius: '999px', + cursor: 'pointer', pointerEvents: 'auto', + display: 'none', userSelect: 'none', + boxShadow: '0 1px 3px rgba(0,0,0,0.2)', + }); + annotOverlayEl.appendChild(annotClearChipEl); + + annotOverlayEl.addEventListener('pointerdown', onAnnotDown); + annotOverlayEl.addEventListener('pointermove', onAnnotMove); + annotOverlayEl.addEventListener('pointerup', onAnnotUp); + annotOverlayEl.addEventListener('pointercancel', onAnnotUp); + document.body.appendChild(annotOverlayEl); + } + + function updateClearChip() { + if (!annotClearChipEl) return; + const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0; + annotClearChipEl.style.display = hasAny ? 'block' : 'none'; + } + + function showAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + annotActive = true; + positionAnnotOverlay(el); + annotOverlayEl.style.display = 'block'; + } + + function hideAnnotOverlay() { + annotActive = false; + if (annotOverlayEl) annotOverlayEl.style.display = 'none'; + // Drop any in-progress edit without touching annotState — clearAnnotations + // (if the caller is exiting configure mode) handles state reset. + annotEditing = null; + } + + function positionAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + const r = el.getBoundingClientRect(); + Object.assign(annotOverlayEl.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height); + } + + function clearAnnotations() { + annotState.comments = []; + annotState.strokes = []; + if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + if (annotPinsEl) annotPinsEl.innerHTML = ''; + annotPointer = null; + annotEditing = null; + annotLastPinClick = { idx: -1, time: 0 }; + updateClearChip(); + } + + // Rebuild the SVG layer. Each stroke gets a wider invisible hit path + // beneath the visible magenta path so clicks register on thin lines. + function redrawStrokes() { + while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + annotState.strokes.forEach((s, idx) => { + const d = pointsToPath(s.points); + const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + hit.setAttribute('d', d); + hit.setAttribute('stroke', 'transparent'); + hit.setAttribute('stroke-width', '16'); + hit.setAttribute('stroke-linecap', 'round'); + hit.setAttribute('stroke-linejoin', 'round'); + hit.setAttribute('fill', 'none'); + hit.setAttribute('pointer-events', 'stroke'); + hit.style.cursor = 'pointer'; + hit.dataset.annotStroke = String(idx); + annotSvgEl.appendChild(hit); + const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + visible.setAttribute('d', d); + visible.setAttribute('stroke', C.brand); + visible.setAttribute('stroke-width', '3'); + visible.setAttribute('stroke-linecap', 'round'); + visible.setAttribute('stroke-linejoin', 'round'); + visible.setAttribute('fill', 'none'); + visible.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(visible); + }); + updateClearChip(); + } + + function localCoords(e) { + const rect = annotOverlayEl.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + function onAnnotDown(e) { + if (!annotActive) return; + + // 1) Clear chip → wipe all annotations + if (e.target.closest?.('[data-annot-clear]')) { + if (annotEditing) annotEditing = null; + clearAnnotations(); + renderAllPins(); + redrawStrokes(); + e.stopPropagation(); e.preventDefault(); + return; + } + + // 2) Stroke hit path → delete that stroke + const strokeHit = e.target.closest?.('[data-annot-stroke]'); + if (strokeHit) { + const idx = parseInt(strokeHit.dataset.annotStroke, 10); + if (Number.isInteger(idx)) { + annotState.strokes.splice(idx, 1); + redrawStrokes(); + } + e.stopPropagation(); e.preventDefault(); + return; + } + + // 3) Pin → drag, edit, or delete-on-double-click + const pinWrap = e.target.closest?.('[data-annot-pin]'); + if (pinWrap) { + const idx = parseInt(pinWrap.dataset.annotPin, 10); + if (!Number.isInteger(idx)) return; + // Double-click (two pointerdowns on the same pin within window) → delete. + const now = Date.now(); + if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) { + if (annotEditing && annotEditing.idx === idx) annotEditing = null; + annotState.comments.splice(idx, 1); + annotLastPinClick = { idx: -1, time: 0 }; + renderAllPins(); + e.stopPropagation(); e.preventDefault(); + return; + } + annotLastPinClick = { idx, time: now }; + // If editing a different pin, commit that edit before starting here. + if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin(); + // If already editing THIS pin and the user clicked the dot, let the + // input keep focus (don't start a drag — the click wasn't meant as one). + if (annotEditing && annotEditing.idx === idx) return; + const p = localCoords(e); + const pin = annotState.comments[idx]; + annotPointer = { + kind: 'pin', idx, + startPointer: p, + startPin: { x: pin.x, y: pin.y }, + moved: false, + }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + return; + } + + // 4) Empty area → commit any open edit, then start new annotation + if (annotEditing) { + finalizeEditingPin(); + e.stopPropagation(); e.preventDefault(); + return; + } + const p = localCoords(e); + annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + } + + function onAnnotMove(e) { + if (!annotActive || !annotPointer) return; + const p = localCoords(e); + + if (annotPointer.kind === 'pin') { + const dx = p.x - annotPointer.startPointer.x; + const dy = p.y - annotPointer.startPointer.y; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + } + const pin = annotState.comments[annotPointer.idx]; + if (!pin) { annotPointer = null; return; } + pin.x = annotPointer.startPin.x + dx; + pin.y = annotPointer.startPin.y + dy; + renderAllPins(); + e.stopPropagation(); + return; + } + + // kind === 'new' + const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + strokeEl.setAttribute('stroke', C.brand); + strokeEl.setAttribute('stroke-width', '3'); + strokeEl.setAttribute('stroke-linecap', 'round'); + strokeEl.setAttribute('stroke-linejoin', 'round'); + strokeEl.setAttribute('fill', 'none'); + strokeEl.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(strokeEl); + annotPointer.strokeEl = strokeEl; + annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]]; + } + annotPointer.strokePoints.push([p.x, p.y]); + annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints)); + e.stopPropagation(); + } + + function onAnnotUp(e) { + if (!annotActive || !annotPointer) return; + + if (annotPointer.kind === 'pin') { + const wasDrag = annotPointer.moved; + const idx = annotPointer.idx; + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + if (wasDrag) { + // A drag is an intentional reposition; a follow-up click shouldn't be + // interpreted as a double-click-to-delete. + annotLastPinClick = { idx: -1, time: 0 }; + } else { + beginEditPin(idx); + } + e.stopPropagation(); + return; + } + + // kind === 'new' + const wasDrag = annotPointer.moved; + if (wasDrag) { + annotState.strokes.push({ points: annotPointer.strokePoints }); + // Swap the temporary preview SVG path for the full render with hit paths. + redrawStrokes(); + } else { + const idx = annotState.comments.length; + annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' }); + renderAllPins(); + beginEditPin(idx); + } + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + e.stopPropagation(); + } + + function pointsToPath(points) { + if (!points || points.length === 0) return ''; + let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1); + for (let i = 1; i < points.length; i++) { + d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1); + } + return d; + } + + function renderAllPins() { + annotPinsEl.innerHTML = ''; + annotState.comments.forEach((c, idx) => { + annotPinsEl.appendChild(buildPinElement(c, idx)); + }); + updateClearChip(); + } + + function buildPinElement(comment, idx) { + const interactive = idx >= 0; + const wrap = document.createElement('div'); + if (interactive) wrap.dataset.annotPin = String(idx); + Object.assign(wrap.style, { + position: 'absolute', + left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px', + pointerEvents: interactive ? 'auto' : 'none', + display: 'flex', alignItems: 'flex-start', gap: '6px', + cursor: interactive ? 'grab' : 'default', + touchAction: 'none', + }); + const dot = document.createElement('div'); + Object.assign(dot.style, { + width: '14px', height: '14px', borderRadius: '50%', + background: C.brand, border: '2px solid ' + C.white, + boxShadow: '0 1px 3px rgba(0,0,0,0.25)', + flexShrink: '0', + }); + wrap.appendChild(dot); + + if (comment.text) { + const bubble = document.createElement('div'); + bubble.textContent = comment.text; + Object.assign(bubble.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + marginTop: '-2px', maxWidth: '220px', + pointerEvents: 'none', whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }); + wrap.appendChild(bubble); + } + return wrap; + } + + function beginEditPin(idx) { + const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]'); + if (!wrapEl) return; + // Strip any existing bubble (but keep the dot) + wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove()); + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Note…'; + Object.assign(input.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + border: '1px solid ' + C.brand, + outline: 'none', marginTop: '-2px', + width: '220px', pointerEvents: 'auto', + }); + const originalText = annotState.comments[idx].text || ''; + input.value = originalText; + wrapEl.appendChild(input); + annotEditing = { idx, input, wrapEl, originalText }; + input.addEventListener('keydown', onAnnotInputKey, true); + input.addEventListener('blur', () => { + // Fires on both focus-loss and programmatic blur; commit unless we + // already handled it. + if (annotEditing && annotEditing.input === input) finalizeEditingPin(); + }); + // Stop clicks/pointerdowns inside the input from bubbling to the overlay + ['pointerdown', 'click'].forEach(ev => { + input.addEventListener(ev, e => e.stopPropagation()); + }); + setTimeout(() => input.focus(), 0); + } + + function onAnnotInputKey(e) { + if (e.key === 'Enter') { + e.preventDefault(); e.stopPropagation(); + finalizeEditingPin(); + } else if (e.key === 'Escape') { + e.preventDefault(); e.stopPropagation(); + cancelEditingPin(); + } else { + // Keep arrows / backspace from hitting global handlers + e.stopPropagation(); + } + } + + function finalizeEditingPin() { + if (!annotEditing) return; + const { idx, input } = annotEditing; + const text = input.value.trim(); + annotEditing = null; + if (text) annotState.comments[idx].text = text; + else annotState.comments.splice(idx, 1); + renderAllPins(); + } + + function cancelEditingPin() { + if (!annotEditing) return; + const { idx, originalText } = annotEditing; + annotEditing = null; + // If the pin had text before this edit, revert to it. If it was a + // just-created empty pin, Escape removes it. + if (originalText) { + annotState.comments[idx].text = originalText; + } else { + annotState.comments.splice(idx, 1); + } + renderAllPins(); + } + + // Build a detached annotation subtree suitable for injection into the clone + // modern-screenshot creates. Coordinates are element-local so this slots + // straight into an element that's been made position:relative. Takes an + // explicit snapshot so it works after annotState has been cleared. + function buildAnnotationsForCapture(rect, snapshot) { + const comments = snapshot ? snapshot.comments : annotState.comments; + const strokes = snapshot ? snapshot.strokes : annotState.strokes; + if (comments.length === 0 && strokes.length === 0) return null; + const wrap = document.createElement('div'); + Object.assign(wrap.style, { + position: 'absolute', top: '0', left: '0', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', overflow: 'visible', + }); + if (strokes.length > 0) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height); + Object.assign(svg.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', overflow: 'visible', + }); + for (const s of strokes) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('stroke', C.brand); + path.setAttribute('stroke-width', '3'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('fill', 'none'); + path.setAttribute('d', pointsToPath(s.points)); + svg.appendChild(path); + } + wrap.appendChild(svg); + } + for (const c of comments) { + // idx=-1 means non-interactive; pointerEvents stay off in the clone + wrap.appendChild(buildPinElement(c, -1)); + } + return wrap; } // --------------------------------------------------------------------------- @@ -416,8 +901,10 @@ fontSize: '11px', color: C.ash, whiteSpace: 'nowrap', marginLeft: 'auto', }); + // Variants currently arrive atomically in a single file edit, so a + // per-variant counter would lie. Say what's true. status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + (arrivedVariants + 1) + ' of ' + expectedVariants + '...' + ? 'Generating ' + expectedVariants + ' variants...' : 'Done'; row.appendChild(status); @@ -838,6 +1325,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + hideShaderOverlay(); updateBarContent('cycling'); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -860,6 +1348,10 @@ positionBar(); showHighlight(selectedElement); } + if (annotActive) positionAnnotOverlay(selectedElement); + // Shader overlay (via debug P toggle or generation) is repositioned + // by its own branch below; debug no longer has a separate overlay. + if (shaderState) positionShaderOverlay(); scrollRaf = requestAnimationFrame(tick); } scrollRaf = requestAnimationFrame(tick); @@ -937,6 +1429,8 @@ } hideBar(); hideHighlight(); + hideShaderOverlay(); + hideAnnotOverlay(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } clearSession(); @@ -976,6 +1470,8 @@ if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { hideBar(); stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -989,15 +1485,19 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); } function handleKeyDown(e) { + // When the annotation input is focused, let it handle its own keys. + if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); state = 'PICKING'; return; } + if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { hideHighlight(); state = 'IDLE'; return; } @@ -1024,6 +1524,8 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); return; @@ -1035,6 +1537,8 @@ } else { // CONFIGURING: re-select the new element and refresh the bar selectedElement = next; + clearAnnotations(); + showAnnotOverlay(next); showBar('configure'); startScrollTracking(); } @@ -1056,25 +1560,408 @@ const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; + // Commit any pending pin edit BEFORE we snapshot annotations. + if (annotEditing) finalizeEditingPin(); + currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; - sendEvent({ + // Flip to GENERATING immediately so the bar morphs without waiting on + // capture + upload. The event is emitted from captureAndEmit() once the + // screenshot is uploaded (or capture fails — we still emit, just without + // screenshotPath). + const elForCapture = selectedElement; + const captureRect = elForCapture.getBoundingClientRect(); + const snapshot = { + comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), + strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })), + }; + const basePayload = { type: 'generate', id: currentSessionId, action: selectedAction, freeformPrompt: prompt || undefined, count: selectedCount, pageUrl: location.pathname, - element: extractContext(selectedElement), - }); + element: extractContext(elForCapture), + }; + if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments; + if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes; + + // Hide the interactive overlay so it doesn't linger during generation. + hideAnnotOverlay(); + clearAnnotations(); state = 'GENERATING'; showBar('generating'); saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + + captureAndEmit(elForCapture, basePayload, snapshot, captureRect); + } + + // --------------------------------------------------------------------------- + // Screenshot capture + upload + // --------------------------------------------------------------------------- + + let msLoadPromise = null; + function loadModernScreenshot() { + if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot); + if (msLoadPromise) return msLoadPromise; + msLoadPromise = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; + s.onload = () => resolve(window.modernScreenshot); + s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; + document.head.appendChild(s); + }); + return msLoadPromise; + } + + // Collect @font-face rules from every stylesheet on the page. Cross-origin + // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules + // access, so modern-screenshot can't embed them on its own — the resulting + // SVG falls back to system fonts and text re-wraps + renders with different + // weight. We fetch the raw CSS text (CORS-permitted for these providers), + // extract @font-face blocks, inline the referenced font files as base64 + // data URIs (SVGs rasterized via canvas can't fetch external resources, + // so URLs inside the SVG silently fail without this), and pass the result + // to modern-screenshot as font.cssText. + const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i; + const FONT_MIME = { + woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject', + }; + function bufferToBase64(buf) { + const bytes = new Uint8Array(buf); + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); + } + async function inlineFontUrls(cssText) { + const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g; + const urls = new Set(); + let m; + while ((m = urlRe.exec(cssText))) { + if (FONT_EXT_RE.test(m[2])) urls.add(m[2]); + } + const map = new Map(); + await Promise.all([...urls].map(async (url) => { + try { + const res = await fetch(url); + if (!res.ok) return; + const buf = await res.arrayBuffer(); + const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2'; + const mime = FONT_MIME[ext] || 'application/octet-stream'; + map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf)); + } catch { /* skip; fall through to URL */ } + })); + return cssText.replace(urlRe, (orig, q, url) => { + const data = map.get(url); + return data ? 'url(' + q + data + q + ')' : orig; + }); + } + async function collectFontCssText() { + const chunks = []; + const fontFaceRe = /@font-face\s*\{[^}]*\}/g; + for (const sheet of document.styleSheets) { + try { + const rules = sheet.cssRules; + for (const rule of rules) { + if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) { + chunks.push(rule.cssText); + } + } + } catch { + if (!sheet.href) continue; + try { + const res = await fetch(sheet.href); + if (!res.ok) continue; + const text = await res.text(); + let m2; + while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]); + } catch { /* ignore; capture is best-effort */ } + } + } + if (chunks.length === 0) return ''; + return inlineFontUrls(chunks.join('\n')); + } + + // Capture the element (with current annotations baked in) and return a PNG + // Blob. Shared between the Go flow (uploads it to the server) and the + // debug toggle (displays it as an overlay for side-by-side comparison). + async function captureElementToBlob(el, snapshot, rect) { + try { if (document.fonts?.ready) await document.fonts.ready; } catch {} + const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); + let annotNode = null; + let savedPosition = null; + if (hasAnnotations) { + const pos = getComputedStyle(el).position; + if (pos === 'static') { + savedPosition = el.style.position; + el.style.position = 'relative'; + } + annotNode = buildAnnotationsForCapture(rect, snapshot); + el.appendChild(annotNode); + } + try { + const ms = await loadModernScreenshot(); + const fontCssText = await collectFontCssText(); + return await ms.domToBlob(el, { + scale: Math.min(window.devicePixelRatio || 1, 2), + backgroundColor: getComputedStyle(document.body).backgroundColor || '#ffffff', + font: fontCssText ? { cssText: fontCssText } : undefined, + }); + } finally { + if (annotNode) annotNode.remove(); + if (savedPosition !== null) el.style.position = savedPosition; + } + } + + async function captureAndEmit(el, basePayload, snapshot, rect) { + let screenshotPath; + let blob; + try { + blob = await captureElementToBlob(el, snapshot, rect); + } catch (err) { + console.warn('[impeccable] capture failed, proceeding without screenshot:', err); + } + // Light up the shader overlay the moment capture is ready — no reason to + // wait for the upload to complete before the user sees something alive. + if (blob && state === 'GENERATING') { + showShaderOverlay(el, blob, rect); + } + if (blob) { + try { + const uploadRes = await fetch( + 'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) + + '&eventId=' + encodeURIComponent(basePayload.id), + { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob }, + ); + if (uploadRes.ok) { + const { path: p } = await uploadRes.json(); + screenshotPath = p; + } else { + console.warn('[impeccable] annotation upload failed:', uploadRes.status); + } + } catch (err) { + console.warn('[impeccable] annotation upload failed:', err); + } + } + sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); + } + + // --------------------------------------------------------------------------- + // Shader overlay — renders the captured screenshot as a WebGL texture and + // runs an editorial "ink-wash" fragment shader over it during generation. + // A single rolling band sweeps top-to-bottom, desaturating + tinting magenta + // and leaving a soft trail. Makes the wait feel like a letterpress scan + // instead of a dead spinner. + // --------------------------------------------------------------------------- + + const SHADER_VS = `attribute vec2 a_position; +attribute vec2 a_uv; +varying vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_position, 0.0, 1.0); +}`; + + const SHADER_FS = `precision highp float; +uniform sampler2D u_texture; +uniform float u_time; +uniform vec2 u_resolution; +uniform vec3 u_accent; +varying vec2 v_uv; + +// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at +// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean +// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below" +// failure that reversed-edge smoothstep would give). +float bandAt(float d, float leadW, float trailW) { + float above = smoothstep(-leadW, 0.0, d); + float below = 1.0 - smoothstep(0.0, trailW, d); + return above * below; +} + +void main() { + vec2 uv = v_uv; + // Roller sweeps top-to-bottom with small overshoot so each cycle enters + // and exits the element cleanly. + float phase = fract(u_time / 3.4); + float y = phase * 1.25 - 0.12; + float band = bandAt(uv.y - y, 0.05, 0.32); + + // Halftone cell grid (fixed ~10 px pitch). + float cellPx = 10.0; + vec2 gridUv = uv * u_resolution / cellPx; + vec2 cellId = floor(gridUv); + vec2 cellUv = fract(gridUv) - 0.5; + vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution; + vec3 cellImg = texture2D(u_texture, sampleCenter).rgb; + float luma = dot(cellImg, vec3(0.299, 0.587, 0.114)); + // Darker cells → bigger magenta dots (classic risograph halftone curve). + float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56; + float dotMask = smoothstep(radius + 0.06, radius, length(cellUv)); + vec3 paper = vec3(0.975, 0.965, 0.955); + vec3 dotLayer = mix(paper, u_accent, dotMask); + + // Blend the halftone layer in where the roller is passing; leave the + // element pristine elsewhere. + vec3 base = texture2D(u_texture, uv).rgb; + gl_FragColor = vec4(mix(base, dotLayer, band), 1.0); +}`; + + // Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350)) + const SHADER_ACCENT = [0.82, 0.16, 0.47]; + let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + + function compileShader(gl, type, source) { + const sh = gl.createShader(type); + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(sh); + gl.deleteShader(sh); + throw new Error('shader compile failed: ' + info); + } + return sh; + } + + function positionShaderOverlay() { + if (!shaderState || !selectedElement) return; + const r = selectedElement.getBoundingClientRect(); + Object.assign(shaderState.canvas.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + } + + function hideShaderOverlay() { + if (!shaderState) return; + if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); + if (shaderState.canvas) shaderState.canvas.remove(); + const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + shaderState = null; + } + + async function showShaderOverlay(el, blob, rect) { + hideShaderOverlay(); + if (!blob || !el) return; + const canvas = document.createElement('canvas'); + canvas.id = PREFIX + '-shader'; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + Object.assign(canvas.style, { + position: 'fixed', + top: rect.top + 'px', left: rect.left + 'px', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', + zIndex: Z.bar - 1, + }); + document.body.appendChild(canvas); + + const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) + || canvas.getContext('experimental-webgl'); + if (!gl) { + // WebGL unavailable — fall back to a plain overlay so the user + // still sees something meaningful during generation. + canvas.remove(); + const img = document.createElement('img'); + img.src = URL.createObjectURL(blob); + img.id = PREFIX + '-shader'; + Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' }); + document.body.appendChild(img); + shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 }; + return; + } + + let program, texture; + try { + const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS); + program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error('program link failed: ' + gl.getProgramInfoLog(program)); + } + // Full-screen quad + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + -1, -1, 0, 1, + 1, -1, 1, 1, + -1, 1, 0, 0, + -1, 1, 0, 0, + 1, -1, 1, 1, + 1, 1, 1, 0, + ]), gl.STATIC_DRAW); + const posLoc = gl.getAttribLocation(program, 'a_position'); + const uvLoc = gl.getAttribLocation(program, 'a_uv'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(uvLoc); + gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8); + } catch (err) { + console.warn('[impeccable] shader setup failed:', err); + canvas.remove(); + return; + } + + // Upload the screenshot as a texture + let bitmap; + try { + bitmap = await createImageBitmap(blob); + } catch { + // Safari fallback: go via a regular Image + const imgUrl = URL.createObjectURL(blob); + const img = new Image(); + img.src = imgUrl; + await new Promise((r, rej) => { img.onload = r; img.onerror = rej; }); + bitmap = img; + URL.revokeObjectURL(imgUrl); + } + texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + if (bitmap.close) bitmap.close(); + + const uTime = gl.getUniformLocation(program, 'u_time'); + const uRes = gl.getUniformLocation(program, 'u_resolution'); + const uAccent = gl.getUniformLocation(program, 'u_accent'); + const uTex = gl.getUniformLocation(program, 'u_texture'); + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; + function frame() { + if (!shaderState) return; + const elapsed = (performance.now() - shaderState.startTime) / 1000; + const t = shaderState.reduced ? 0.0 : elapsed; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.useProgram(program); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.uniform1i(uTex, 0); + gl.uniform1f(uTime, t); + gl.uniform2f(uRes, canvas.width, canvas.height); + gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]); + gl.drawArrays(gl.TRIANGLES, 0, 6); + shaderState.rafId = requestAnimationFrame(frame); + } + frame(); } function handleAccept() { @@ -2713,6 +3600,7 @@ function init() { initHighlight(); + initAnnotOverlay(); initBar(); initActionPicker(); initGlobalBar(); diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs index 109aa768e..97163b255 100644 --- a/.trae/skills/impeccable/scripts/live-server.mjs +++ b/.trae/skills/impeccable/scripts/live-server.mjs @@ -18,7 +18,6 @@ import { randomUUID } from 'node:crypto'; import { spawn, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; @@ -56,8 +55,13 @@ const state = { pendingEvents: [], // browser events waiting for agent poll pendingPolls: [], // agent poll callbacks waiting for browser events exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots }; +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + function enqueueEvent(event) { if (state.pendingPolls.length > 0) { state.pendingPolls.shift()(event); @@ -134,6 +138,10 @@ function validateEvent(msg) { if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; return null; case 'accept': if (!msg.id) return 'accept: missing id'; @@ -175,6 +183,83 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { return; } + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + // --- Health --- if (p === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -250,10 +335,11 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } const absPath = path.resolve(process.cwd(), filePath); if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } - try { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(fs.readFileSync(absPath, 'utf-8')); - } catch { res.writeHead(404); res.end('File not found'); } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); return; } @@ -411,6 +497,9 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } for (const res of state.sseClients) { try { res.end(); } catch {} } state.sseClients.clear(); for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); @@ -442,12 +531,14 @@ Options: --help Show this help Endpoints: - /live.js Browser script (element picker + variant cycling) - /detect.js Detection overlay (backwards compatible) - /events SSE stream (server→browser) + POST (browser→server) - /poll Long-poll for agent CLI - /source Raw source file reader (no-HMR fallback) - /health Health check`); + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); process.exit(0); } @@ -531,6 +622,12 @@ try { state.token = randomUUID(); const portArg = args.find(a => a.startsWith('--port=')); state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); const { detectScript, liveScript } = loadBrowserScripts(); const liveScriptWithToken = diff --git a/.trae/skills/impeccable/scripts/modern-screenshot.umd.js b/.trae/skills/impeccable/scripts/modern-screenshot.umd.js new file mode 100644 index 000000000..a9c5208f6 --- /dev/null +++ b/.trae/skills/impeccable/scripts/modern-screenshot.umd.js @@ -0,0 +1,14 @@ +(function(y,v){typeof exports=="object"&&typeof module!="undefined"?v(exports):typeof define=="function"&&define.amd?define(["exports"],v):(y=typeof globalThis!="undefined"?globalThis:y||self,v(y.modernScreenshot={}))})(this,function(y){"use strict";var rr=Object.defineProperty,nr=Object.defineProperties;var or=Object.getOwnPropertyDescriptors;var Z=Object.getOwnPropertySymbols;var xe=Object.prototype.hasOwnProperty,Me=Object.prototype.propertyIsEnumerable;var Oe=Math.pow,Le=(y,v,N)=>v in y?rr(y,v,{enumerable:!0,configurable:!0,writable:!0,value:N}):y[v]=N,D=(y,v)=>{for(var N in v||(v={}))xe.call(v,N)&&Le(y,N,v[N]);if(Z)for(var N of Z(v))Me.call(v,N)&&Le(y,N,v[N]);return y},M=(y,v)=>nr(y,or(v));var je=(y,v)=>{var N={};for(var R in y)xe.call(y,R)&&v.indexOf(R)<0&&(N[R]=y[R]);if(y!=null&&Z)for(var R of Z(y))v.indexOf(R)<0&&Me.call(y,R)&&(N[R]=y[R]);return N};var C=(y,v,N)=>new Promise((R,O)=>{var X=P=>{try{q(N.next(P))}catch(W){O(W)}},j=P=>{try{q(N.throw(P))}catch(W){O(W)}},q=P=>P.done?R(P.value):Promise.resolve(P.value).then(X,j);q((N=N.apply(y,v)).next())});var Be;function v(e,t){return e[13]=1,e[14]=t>>8,e[15]=t&255,e[16]=t>>8,e[17]=t&255,e}const N=112,R=72,O=89,X=115;let j;function q(){const e=new Int32Array(256);for(let t=0;t<256;t++){let r=t;for(let n=0;n<8;n++)r=r&1?3988292384^r>>>1:r>>>1;e[t]=r}return e}function P(e){let t=-1;j||(j=q());for(let r=0;r>>8;return t^-1}function W(e){const t=e.length-1;for(let r=t;r>=4;r--)if(e[r-4]===9&&e[r-3]===N&&e[r-2]===R&&e[r-1]===O&&e[r]===X)return r-3;return 0}function ae(e,t,r=!1){const n=new Uint8Array(13);t*=39.3701,n[0]=N,n[1]=R,n[2]=O,n[3]=X,n[4]=t>>>24,n[5]=t>>>16,n[6]=t>>>8,n[7]=t&255,n[8]=n[4],n[9]=n[5],n[10]=n[6],n[11]=n[7],n[12]=1;const i=P(n),a=new Uint8Array(4);if(a[0]=i>>>24,a[1]=i>>>16,a[2]=i>>>8,a[3]=i&255,r){const s=W(e);return e.set(n,s),e.set(a,s+13),e}else{const s=new Uint8Array(4);s[0]=0,s[1]=0,s[2]=0,s[3]=9;const o=new Uint8Array(54);return o.set(e,0),o.set(s,33),o.set(n,37),o.set(a,50),o}}const qe="AAlwSFlz",We="AAAJcEhZ",He="AAAACXBI";function Ve(e){let t=e.indexOf(qe);return t===-1&&(t=e.indexOf(We)),t===-1&&(t=e.indexOf(He)),t}const se="[modern-screenshot]",U=typeof window!="undefined",ze=U&&"Worker"in window,ie=U&&"atob"in window,Xe=U&&"btoa"in window,ee=U?(Be=window.navigator)==null?void 0:Be.userAgent:"",le=ee.includes("Chrome"),G=ee.includes("AppleWebKit")&&!le,te=ee.includes("Firefox"),Ge=e=>e&&"__CONTEXT__"in e,Ye=e=>e.constructor.name==="CSSFontFaceRule",Je=e=>e.constructor.name==="CSSImportRule",Ke=e=>e.constructor.name==="CSSLayerBlockRule",I=e=>e.nodeType===1,H=e=>typeof e.className=="object",ce=e=>e.tagName==="image",Qe=e=>e.tagName==="use",V=e=>I(e)&&typeof e.style!="undefined"&&!H(e),Ze=e=>e.nodeType===8,et=e=>e.nodeType===3,$=e=>e.tagName==="IMG",Y=e=>e.tagName==="VIDEO",tt=e=>e.tagName==="CANVAS",rt=e=>e.tagName==="TEXTAREA",nt=e=>e.tagName==="INPUT",ot=e=>e.tagName==="STYLE",at=e=>e.tagName==="SCRIPT",st=e=>e.tagName==="SELECT",it=e=>e.tagName==="SLOT",lt=e=>e.tagName==="IFRAME",ct=(...e)=>console.warn(se,...e);function ut(e){var r;const t=(r=e==null?void 0:e.createElement)==null?void 0:r.call(e,"canvas");return t&&(t.height=t.width=1),!!t&&"toDataURL"in t&&!!t.toDataURL("image/webp").includes("image/webp")}const re=e=>e.startsWith("data:");function ue(e,t){if(e.match(/^[a-z]+:\/\//i))return e;if(U&&e.match(/^\/\//))return window.location.protocol+e;if(e.match(/^[a-z]+:/i)||!U)return e;const r=J().implementation.createHTMLDocument(),n=r.createElement("base"),i=r.createElement("a");return r.head.appendChild(n),r.body.appendChild(i),t&&(n.href=t),i.href=e,i.href}function J(e){var t;return(t=e&&I(e)?e==null?void 0:e.ownerDocument:e)!=null?t:window.document}const K="http://www.w3.org/2000/svg";function fe(e,t,r){const n=J(r).createElementNS(K,"svg");return n.setAttributeNS(null,"width",e.toString()),n.setAttributeNS(null,"height",t.toString()),n.setAttributeNS(null,"viewBox",`0 0 ${e} ${t}`),n}function de(e,t){let r=new XMLSerializer().serializeToString(e);return t&&(r=r.replace(/[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu,"")),`data:image/svg+xml;charset=utf-8,${encodeURIComponent(r)}`}function ft(e,t="image/png",r=1){return C(this,null,function*(){try{return yield new Promise((n,i)=>{e.toBlob(a=>{a?n(a):i(new Error("Blob is null"))},t,r)})}catch(n){if(ie)return dt(e.toDataURL(t,r));throw n}})}function dt(e){var o,c;const[t,r]=e.split(","),n=(c=(o=t.match(/data:(.+);/))==null?void 0:o[1])!=null?c:void 0,i=window.atob(r),a=i.length,s=new Uint8Array(a);for(let u=0;u{const i=new FileReader;i.onload=()=>r(i.result),i.onerror=()=>n(i.error),i.onabort=()=>n(new Error(`Failed read blob to ${t}`)),t==="dataUrl"?i.readAsDataURL(e):t==="arrayBuffer"&&i.readAsArrayBuffer(e)})}const gt=e=>ge(e,"dataUrl"),mt=e=>ge(e,"arrayBuffer");function _(e,t){const r=J(t).createElement("img");return r.decoding="sync",r.loading="eager",r.src=e,r}function L(e,t){return new Promise(r=>{const{timeout:n,ownerDocument:i,onError:a,onWarn:s}=t!=null?t:{},o=typeof e=="string"?_(e,J(i)):e;let c=null,u=null;function l(){r(o),c&&clearTimeout(c),u==null||u()}if(n&&(c=setTimeout(l,n)),Y(o)){const d=o.currentSrc||o.src;if(!d)return o.poster?L(o.poster,t).then(r):l();if(o.readyState>=2)return l();const m=l,f=h=>{s==null||s("Failed video load",d,h),a==null||a(h),l()};u=()=>{o.removeEventListener("loadeddata",m),o.removeEventListener("error",f)},o.addEventListener("loadeddata",m,{once:!0}),o.addEventListener("error",f,{once:!0})}else{const d=ce(o)?o.href.baseVal:o.currentSrc||o.src;if(!d)return l();const m=()=>C(this,null,function*(){if($(o)&&"decode"in o)try{yield o.decode()}catch(h){s==null||s("Failed to decode image, trying to render anyway",o.dataset.originalSrc||d,h)}l()}),f=h=>{s==null||s("Failed image load",o.dataset.originalSrc||d,h),l()};if($(o)&&o.complete)return m();u=()=>{o.removeEventListener("load",m),o.removeEventListener("error",f)},o.addEventListener("load",m,{once:!0}),o.addEventListener("error",f,{once:!0})}})}function me(e,t){return C(this,null,function*(){V(e)&&($(e)||Y(e)?yield L(e,t):yield Promise.all(["img","video"].flatMap(r=>Array.from(e.querySelectorAll(r)).map(n=>L(n,t)))))})}const he=function(){let t=0;const r=()=>`0000${(Math.random()*Oe(36,4)<<0).toString(36)}`.slice(-4);return()=>(t+=1,`u${r()}${t}`)}();function we(e){return e==null?void 0:e.split(",").map(t=>t.trim().replace(/"|'/g,"").toLowerCase()).filter(Boolean)}let pe=0;function ht(e){const t=`${se}[#${pe}]`;return pe++,{time:r=>e&&console.time(`${t} ${r}`),timeEnd:r=>e&&console.timeEnd(`${t} ${r}`),warn:(...r)=>e&&ct(...r)}}function wt(e){return{cache:e?"no-cache":"force-cache"}}function k(e,t){return C(this,null,function*(){return Ge(e)?e:ye(e,M(D({},t),{autoDestruct:!0}))})}function ye(e,t){return C(this,null,function*(){var f,h,g,p,E;const{scale:r=1,workerUrl:n,workerNumber:i=1}=t||{},a=!!(t!=null&&t.debug),s=(f=t==null?void 0:t.features)!=null?f:!0,o=(h=e.ownerDocument)!=null?h:U?window.document:void 0,c=(p=(g=e.ownerDocument)==null?void 0:g.defaultView)!=null?p:U?window:void 0,u=new Map,l=M(D({width:0,height:0,quality:1,type:"image/png",scale:r,backgroundColor:null,style:null,filter:null,maximumCanvasSize:0,timeout:3e4,progress:null,debug:a,fetch:D({requestInit:wt((E=t==null?void 0:t.fetch)==null?void 0:E.bypassingCache),placeholderImage:"data:image/png;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",bypassingCache:!1},t==null?void 0:t.fetch),fetchFn:null,font:{},drawImageInterval:100,workerUrl:null,workerNumber:i,onCloneEachNode:null,onCloneNode:null,onEmbedNode:null,onCreateForeignObjectSvg:null,includeStyleProperties:null,autoDestruct:!1},t),{__CONTEXT__:!0,log:ht(a),node:e,ownerDocument:o,ownerWindow:c,dpi:r===1?null:96*r,svgStyleElement:be(o),svgDefsElement:o==null?void 0:o.createElementNS(K,"defs"),svgStyles:new Map,defaultComputedStyles:new Map,workers:[...Array.from({length:ze&&n&&i?i:0})].map(()=>{try{const b=new Worker(n);return b.onmessage=w=>C(this,null,function*(){var A,F,B,$e;const{url:S,result:T}=w.data;T?(F=(A=u.get(S))==null?void 0:A.resolve)==null||F.call(A,T):($e=(B=u.get(S))==null?void 0:B.reject)==null||$e.call(B,new Error(`Error receiving message from worker: ${S}`))}),b.onmessageerror=w=>{var T,A;const{url:S}=w.data;(A=(T=u.get(S))==null?void 0:T.reject)==null||A.call(T,new Error(`Error receiving message from worker: ${S}`))},b}catch(b){return l.log.warn("Failed to new Worker",b),null}}).filter(Boolean),fontFamilies:new Map,fontCssTexts:new Map,acceptOfImage:`${[ut(o)&&"image/webp","image/svg+xml","image/*","*/*"].filter(Boolean).join(",")};q=0.8`,requests:u,drawImageCount:0,tasks:[],features:s,isEnable:b=>{var w,S;return b==="restoreScrollPosition"?typeof s=="boolean"?!1:(w=s[b])!=null?w:!1:typeof s=="boolean"?s:(S=s[b])!=null?S:!0},shadowRoots:[]});l.log.time("wait until load"),yield me(e,{timeout:l.timeout,onWarn:l.log.warn}),l.log.timeEnd("wait until load");const{width:d,height:m}=pt(e,l);return l.width=d,l.height=m,l})}function be(e){if(!e)return;const t=e.createElement("style"),r=t.ownerDocument.createTextNode(` +.______background-clip--text { + background-clip: text; + -webkit-background-clip: text; +} +`);return t.appendChild(r),t}function pt(e,t){let{width:r,height:n}=t;if(I(e)&&(!r||!n)){const i=e.getBoundingClientRect();r=r||i.width||Number(e.getAttribute("width"))||0,n=n||i.height||Number(e.getAttribute("height"))||0}return{width:r,height:n}}function yt(e,t){return C(this,null,function*(){const{log:r,timeout:n,drawImageCount:i,drawImageInterval:a}=t;r.time("image to canvas");const s=yield L(e,{timeout:n,onWarn:t.log.warn}),{canvas:o,context2d:c}=bt(e.ownerDocument,t),u=()=>{try{c==null||c.drawImage(s,0,0,o.width,o.height)}catch(l){t.log.warn("Failed to drawImage",l)}};if(u(),t.isEnable("fixSvgXmlDecode"))for(let l=0;l{setTimeout(()=>{c==null||c.clearRect(0,0,o.width,o.height),u(),d()},l+a)});return t.drawImageCount=0,r.timeEnd("image to canvas"),o})}function bt(e,t){const{width:r,height:n,scale:i,backgroundColor:a,maximumCanvasSize:s}=t,o=e.createElement("canvas");o.width=Math.floor(r*i),o.height=Math.floor(n*i),o.style.width=`${r}px`,o.style.height=`${n}px`,s&&(o.width>s||o.height>s)&&(o.width>s&&o.height>s?o.width>o.height?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s):o.width>s?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s));const c=o.getContext("2d");return c&&a&&(c.fillStyle=a,c.fillRect(0,0,o.width,o.height)),{canvas:o,context2d:c}}function Se(e,t){if(e.ownerDocument)try{const a=e.toDataURL();if(a!=="data:,")return _(a,e.ownerDocument)}catch(a){t.log.warn("Failed to clone canvas",a)}const r=e.cloneNode(!1),n=e.getContext("2d"),i=r.getContext("2d");try{return n&&i&&i.putImageData(n.getImageData(0,0,e.width,e.height),0,0),r}catch(a){t.log.warn("Failed to clone canvas",a)}return r}function St(e,t){var r;try{if((r=e==null?void 0:e.contentDocument)!=null&&r.documentElement)return ne(e.contentDocument.documentElement,t)}catch(n){t.log.warn("Failed to clone iframe",n)}return e.cloneNode(!1)}function Et(e){const t=e.cloneNode(!1);return e.currentSrc&&e.currentSrc!==e.src&&(t.src=e.currentSrc,t.srcset=""),t.loading==="lazy"&&(t.loading="eager"),t}function Ct(e,t){return C(this,null,function*(){if(e.ownerDocument&&!e.currentSrc&&e.poster)return _(e.poster,e.ownerDocument);const r=e.cloneNode(!1);r.crossOrigin="anonymous",e.currentSrc&&e.currentSrc!==e.src&&(r.src=e.currentSrc);const n=r.ownerDocument;if(n){let i=!0;if(yield L(r,{onError:()=>i=!1,onWarn:t.log.warn}),!i)return e.poster?_(e.poster,e.ownerDocument):r;r.currentTime=e.currentTime,yield new Promise(s=>{r.addEventListener("seeked",s,{once:!0})});const a=n.createElement("canvas");a.width=e.offsetWidth,a.height=e.offsetHeight;try{const s=a.getContext("2d");s&&s.drawImage(r,0,0,a.width,a.height)}catch(s){return t.log.warn("Failed to clone video",s),e.poster?_(e.poster,e.ownerDocument):r}return Se(a,t)}return r})}function Tt(e,t){return tt(e)?Se(e,t):lt(e)?St(e,t):$(e)?Et(e):Y(e)?Ct(e,t):e.cloneNode(!1)}function vt(e){let t=e.sandbox;if(!t){const{ownerDocument:r}=e;try{r&&(t=r.createElement("iframe"),t.id=`__SANDBOX__${he()}`,t.width="0",t.height="0",t.style.visibility="hidden",t.style.position="fixed",r.body.appendChild(t),t.srcdoc='',e.sandbox=t)}catch(n){e.log.warn("Failed to getSandBox",n)}}return t}const At=["width","height","-webkit-text-fill-color"],Nt=["stroke","fill"];function Ee(e,t,r){const{defaultComputedStyles:n}=r,i=e.nodeName.toLowerCase(),a=H(e)&&i!=="svg",s=a?Nt.map(g=>[g,e.getAttribute(g)]).filter(([,g])=>g!==null):[],o=[a&&"svg",i,s.map((g,p)=>`${g}=${p}`).join(","),t].filter(Boolean).join(":");if(n.has(o))return n.get(o);const c=vt(r),u=c==null?void 0:c.contentWindow;if(!u)return new Map;const l=u==null?void 0:u.document;let d,m;a?(d=l.createElementNS(K,"svg"),m=d.ownerDocument.createElementNS(d.namespaceURI,i),s.forEach(([g,p])=>{m.setAttributeNS(null,g,p)}),d.appendChild(m)):d=m=l.createElement(i),m.textContent=" ",l.body.appendChild(d);const f=u.getComputedStyle(m,t),h=new Map;for(let g=f.length,p=0;pn.set(d,l));function s(c){const u=e.getPropertyValue(c),l=e.getPropertyPriority(c),d=c.lastIndexOf("-"),m=d>-1?c.substring(0,d):void 0;if(m){let f=a.get(m);f||(f=new Map,a.set(m,f)),f.set(c,[u,l])}t.get(c)===u&&!l||(m?i.push(m):n.set(c,[u,l]))}return n}function Rt(e,t,r,n){var d,m,f,h;const{ownerWindow:i,includeStyleProperties:a,currentParentNodeStyle:s}=n,o=t.style,c=i.getComputedStyle(e),u=Ee(e,null,n);s==null||s.forEach((g,p)=>{u.delete(p)});const l=Ce(c,u,a);l.delete("transition-property"),l.delete("all"),l.delete("d"),l.delete("content"),r&&(l.delete("position"),l.delete("margin-top"),l.delete("margin-right"),l.delete("margin-bottom"),l.delete("margin-left"),l.delete("margin-block-start"),l.delete("margin-block-end"),l.delete("margin-inline-start"),l.delete("margin-inline-end"),l.set("box-sizing",["border-box",""])),((d=l.get("background-clip"))==null?void 0:d[0])==="text"&&t.classList.add("______background-clip--text"),le&&(l.has("font-kerning")||l.set("font-kerning",["normal",""]),(((m=l.get("overflow-x"))==null?void 0:m[0])==="hidden"||((f=l.get("overflow-y"))==null?void 0:f[0])==="hidden")&&((h=l.get("text-overflow"))==null?void 0:h[0])==="ellipsis"&&e.scrollWidth===e.clientWidth&&l.set("text-overflow",["clip",""]));for(let g=o.length,p=0;p{o.setProperty(E,g,p)}),l}function It(e,t){(rt(e)||nt(e)||st(e))&&t.setAttribute("value",e.value)}const kt=["::before","::after"],Dt=["::-webkit-scrollbar","::-webkit-scrollbar-button","::-webkit-scrollbar-thumb","::-webkit-scrollbar-track","::-webkit-scrollbar-track-piece","::-webkit-scrollbar-corner","::-webkit-resizer"];function Pt(e,t,r,n,i){const{ownerWindow:a,svgStyleElement:s,svgStyles:o,currentNodeStyle:c}=n;if(!s||!a)return;function u(l){var w;const d=a.getComputedStyle(e,l);let m=d.getPropertyValue("content");if(!m||m==="none")return;i==null||i(m),m=m.replace(/(')|(")|(counter\(.+\))/g,"");const f=[he()],h=Ee(e,l,n);c==null||c.forEach((S,T)=>{h.delete(T)});const g=Ce(d,h,n.includeStyleProperties);g.delete("content"),g.delete("-webkit-locale"),((w=g.get("background-clip"))==null?void 0:w[0])==="text"&&t.classList.add("______background-clip--text");const p=[`content: '${m}';`];if(g.forEach(([S,T],A)=>{p.push(`${A}: ${S}${T?" !important":""};`)}),p.length===1)return;try{t.className=[t.className,...f].join(" ")}catch(S){n.log.warn("Failed to copyPseudoClass",S);return}const E=p.join(` + `);let b=o.get(E);b||(b=[],o.set(E,b)),b.push(`.${f[0]}${l}`)}kt.forEach(u),r&&Dt.forEach(u)}const Te=new Set(["symbol"]);function ve(e,t,r,n,i){return C(this,null,function*(){if(I(r)&&(ot(r)||at(r))||n.filter&&!n.filter(r))return;Te.has(t.nodeName)||Te.has(r.nodeName)?n.currentParentNodeStyle=void 0:n.currentParentNodeStyle=n.currentNodeStyle;const a=yield ne(r,n,!1,i);n.isEnable("restoreScrollPosition")&&Ut(e,a),t.appendChild(a)})}function Ae(e,t,r,n){return C(this,null,function*(){var a;let i=e.firstChild;I(e)&&e.shadowRoot&&(i=(a=e.shadowRoot)==null?void 0:a.firstChild,r.shadowRoots.push(e.shadowRoot));for(let s=i;s;s=s.nextSibling)if(!Ze(s))if(I(s)&&it(s)&&typeof s.assignedNodes=="function"){const o=s.assignedNodes();for(let c=0;ce.clientHeight||e.scrollWidth>e.clientWidth)}const p=(d=h.get("text-transform"))==null?void 0:d[0],E=we((m=h.get("font-family"))==null?void 0:m[0]),b=E?w=>{p==="uppercase"?w=w.toUpperCase():p==="lowercase"?w=w.toLowerCase():p==="capitalize"&&(w=w[0].toUpperCase()+w.substring(1)),E.forEach(S=>{let T=s.get(S);T||s.set(S,T=new Set),w.split("").forEach(A=>T.add(A))})}:void 0;return Pt(e,f,g,t,b),It(e,f),Y(e)||(yield Ae(e,f,t,b)),yield o==null?void 0:o(f),f}const c=e.cloneNode(!1);return yield Ae(e,c,t),yield o==null?void 0:o(c),c})}function Ne(e){if(e.ownerDocument=void 0,e.ownerWindow=void 0,e.svgStyleElement=void 0,e.svgDefsElement=void 0,e.svgStyles.clear(),e.defaultComputedStyles.clear(),e.sandbox){try{e.sandbox.remove()}catch(t){e.log.warn("Failed to destroyContext",t)}e.sandbox=void 0}e.workers=[],e.fontFamilies.clear(),e.fontCssTexts.clear(),e.requests.clear(),e.tasks=[],e.shadowRoots=[]}function Bt(e){const o=e,{url:t,timeout:r,responseType:n}=o,i=je(o,["url","timeout","responseType"]),a=new AbortController,s=r?setTimeout(()=>a.abort(),r):void 0;return fetch(t,D({signal:a.signal},i)).then(c=>{if(!c.ok)throw new Error("Failed fetch, not 2xx response",{cause:c});switch(n){case"arrayBuffer":return c.arrayBuffer();case"dataUrl":return c.blob().then(gt);case"text":default:return c.text()}}).finally(()=>clearTimeout(s))}function z(e,t){const{url:r,requestType:n="text",responseType:i="text",imageDom:a}=t;let s=r;const{timeout:o,acceptOfImage:c,requests:u,fetchFn:l,fetch:{requestInit:d,bypassingCache:m,placeholderImage:f},font:h,workers:g,fontFamilies:p}=e;n==="image"&&(G||te)&&e.drawImageCount++;let E=u.get(r);if(!E){m&&m instanceof RegExp&&m.test(s)&&(s+=(/\?/.test(s)?"&":"?")+new Date().getTime());const b=n.startsWith("font")&&h&&h.minify,w=new Set;b&&n.split(";")[1].split(",").forEach(F=>{p.has(F)&&p.get(F).forEach(B=>w.add(B))});const S=b&&w.size,T=D({url:s,timeout:o,responseType:S?"arrayBuffer":i,headers:n==="image"?{accept:c}:void 0},d);E={type:n,resolve:void 0,reject:void 0,response:null},E.response=C(this,null,function*(){if(l&&n==="image"){const A=yield l(r);if(A)return A}return!G&&r.startsWith("http")&&g.length?new Promise((A,F)=>{g[u.size&g.length-1].postMessage(D({rawUrl:r},T)),E.resolve=A,E.reject=F}):Bt(T)}).catch(A=>{if(u.delete(r),n==="image"&&f)return e.log.warn("Failed to fetch image base64, trying to use placeholder image",s),typeof f=="string"?f:f(a);throw A}),u.set(r,E)}return E.response}function Re(e,t,r,n){return C(this,null,function*(){if(!Ie(e))return e;for(const[i,a]of $t(e,t))try{const s=yield z(r,{url:a,requestType:n?"image":"text",responseType:"dataUrl"});e=e.replace(Lt(i),`$1${s}$3`)}catch(s){r.log.warn("Failed to fetch css data url",i,s)}return e})}function Ie(e){return/url\((['"]?)([^'"]+?)\1\)/.test(e)}const ke=/url\((['"]?)([^'"]+?)\1\)/g;function $t(e,t){const r=[];return e.replace(ke,(n,i,a)=>(r.push([a,ue(a,t)]),n)),r.filter(([n])=>!re(n))}function Lt(e){const t=e.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${t})(['"]?\\))`,"g")}const xt=["background-image","border-image-source","-webkit-border-image","-webkit-mask-image","list-style-image"];function Mt(e,t){return xt.map(r=>{const n=e.getPropertyValue(r);return!n||n==="none"?null:((G||te)&&t.drawImageCount++,Re(n,null,t,!0).then(i=>{!i||n===i||e.setProperty(r,i,e.getPropertyPriority(r))}))}).filter(Boolean)}function Ot(e,t){if($(e)){const r=e.currentSrc||e.src;if(!re(r))return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.srcset="",e.dataset.originalSrc=r,e.src=n||"")})];(G||te)&&t.drawImageCount++}else if(H(e)&&!re(e.href.baseVal)){const r=e.href.baseVal;return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.dataset.originalSrc=r,e.href.baseVal=n||"")})]}return[]}function jt(e,t){var o;const{ownerDocument:r,svgDefsElement:n}=t,i=(o=e.getAttribute("href"))!=null?o:e.getAttribute("xlink:href");if(!i)return[];const[a,s]=i.split("#");if(s){const c=`#${s}`,u=t.shadowRoots.reduce((l,d)=>l!=null?l:d.querySelector(`svg ${c}`),r==null?void 0:r.querySelector(`svg ${c}`));if(a&&e.setAttribute("href",c),n!=null&&n.querySelector(c))return[];if(u)return n==null||n.appendChild(u.cloneNode(!0)),[];if(a)return[z(t,{url:a,responseType:"text"}).then(l=>{n==null||n.insertAdjacentHTML("beforeend",l)})]}return[]}function De(e,t){const{tasks:r}=t;I(e)&&(($(e)||ce(e))&&r.push(...Ot(e,t)),Qe(e)&&r.push(...jt(e,t))),V(e)&&r.push(...Mt(e.style,t)),e.childNodes.forEach(n=>{De(n,t)})}function qt(e,t){return C(this,null,function*(){const{ownerDocument:r,svgStyleElement:n,fontFamilies:i,fontCssTexts:a,tasks:s,font:o}=t;if(!(!r||!n||!i.size))if(o&&o.cssText){const c=Ue(o.cssText,t);n.appendChild(r.createTextNode(`${c} +`))}else{const c=Array.from(r.styleSheets).filter(f=>{try{return"cssRules"in f&&!!f.cssRules.length}catch(h){return t.log.warn(`Error while reading CSS rules from ${f.href}`,h),!1}}),u=r.implementation.createHTMLDocument(""),l=u.createElement("style");u.head.appendChild(l);const d=l.sheet;yield Promise.all(c.flatMap(f=>Array.from(f.cssRules).map(h=>C(this,null,function*(){if(Je(h)){const g=h.href;let p="";try{p=yield z(t,{url:g,requestType:"text",responseType:"text"})}catch(b){t.log.warn(`Error fetch remote css import from ${g}`,b)}const E=p.replace(ke,(b,w,S)=>b.replace(S,ue(S,g)));for(const b of Ht(E))try{d.insertRule(b,d.cssRules.length)}catch(w){t.log.warn("Error inserting rule from remote css import",{rule:b,error:w})}}})))),d.cssRules.length&&c.push(d);const m=[];c.forEach(f=>{oe(f.cssRules,m)}),m.filter(f=>{var h;return Ye(f)&&Ie(f.style.getPropertyValue("src"))&&((h=we(f.style.getPropertyValue("font-family")))==null?void 0:h.some(g=>i.has(g)))}).forEach(f=>{const h=f,g=a.get(h.cssText);g?n.appendChild(r.createTextNode(`${g} +`)):s.push(Re(h.cssText,h.parentStyleSheet?h.parentStyleSheet.href:null,t).then(p=>{p=Ue(p,t),a.set(h.cssText,p),n.appendChild(r.createTextNode(`${p} +`))}))})}})}const Wt=/(\/\*[\s\S]*?\*\/)/g,Pe=/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi;function Ht(e){if(e==null)return[];const t=[];let r=e.replace(Wt,"");for(;;){const a=Pe.exec(r);if(!a)break;t.push(a[0])}r=r.replace(Pe,"");const n=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,i=new RegExp("((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})","gi");for(;;){let a=n.exec(r);if(a)i.lastIndex=n.lastIndex;else if(a=i.exec(r),a)n.lastIndex=i.lastIndex;else break;t.push(a[0])}return t}const Vt=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,zt=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Ue(e,t){const{font:r}=t,n=r?r==null?void 0:r.preferredFormat:void 0;return n?e.replace(zt,i=>{for(;;){const[a,,s]=Vt.exec(i)||[];if(!s)return"";if(s===n)return`src: ${a};`}}):e}function oe(e,t=[]){for(const r of Array.from(e))Ke(r)?t.push(...oe(r.cssRules)):"cssRules"in r?oe(r.cssRules,t):t.push(r);return t}const Xt=/\bx?link:?href\s*=\s*["'](?!data:)[^"']+["']/i;function Gt(e){return Xt.test(e.innerHTML)}function _e(e,t){return C(this,null,function*(){const r=yield k(e,t);if(I(r.node)&&H(r.node)&&!Gt(r.node))return r.node;const{ownerDocument:n,log:i,tasks:a,svgStyleElement:s,svgDefsElement:o,svgStyles:c,font:u,progress:l,autoDestruct:d,onCloneNode:m,onEmbedNode:f,onCreateForeignObjectSvg:h}=r;i.time("clone node");const g=yield ne(r.node,r,!0);if(s&&n){let S="";c.forEach((T,A)=>{S+=`${T.join(`, +`)} { + ${A} +} +`}),s.appendChild(n.createTextNode(S))}i.timeEnd("clone node"),yield m==null?void 0:m(g),u!==!1&&I(g)&&(i.time("embed web font"),yield qt(g,r),i.timeEnd("embed web font")),i.time("embed node"),De(g,r);const p=a.length;let E=0;const b=()=>C(this,null,function*(){for(;;){const S=a.pop();if(!S)break;try{yield S}catch(T){r.log.warn("Failed to run task",T)}l==null||l(++E,p)}});l==null||l(E,p),yield Promise.all([...Array.from({length:4})].map(b)),i.timeEnd("embed node"),yield f==null?void 0:f(g);const w=Yt(g,r);return o&&w.insertBefore(o,w.children[0]),s&&w.insertBefore(s,w.children[0]),d&&Ne(r),yield h==null?void 0:h(w),w})}function Yt(e,t){const{width:r,height:n}=t,i=fe(r,n,e.ownerDocument),a=i.ownerDocument.createElementNS(i.namespaceURI,"foreignObject");return a.setAttributeNS(null,"x","0%"),a.setAttributeNS(null,"y","0%"),a.setAttributeNS(null,"width","100%"),a.setAttributeNS(null,"height","100%"),a.append(e),i.appendChild(a),i}function Q(e,t){return C(this,null,function*(){var s;const r=yield k(e,t),n=yield _e(r),i=de(n,r.isEnable("removeControlCharacter"));r.autoDestruct||(r.svgStyleElement=be(r.ownerDocument),r.svgDefsElement=(s=r.ownerDocument)==null?void 0:s.createElementNS(K,"defs"),r.svgStyles.clear());const a=_(i,n.ownerDocument);return yield yt(a,r)})}function Jt(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,type:i,quality:a,dpi:s}=r,o=yield Q(r);n.time("canvas to blob");const c=yield ft(o,i,a);if(["image/png","image/jpeg"].includes(i)&&s){const u=yield mt(c.slice(0,33));let l=new Uint8Array(u);return i==="image/png"?l=ae(l,s):i==="image/jpeg"&&(l=v(l,s)),n.timeEnd("canvas to blob"),new Blob([l,c.slice(33)],{type:i})}return n.timeEnd("canvas to blob"),c})}function x(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,quality:i,type:a,dpi:s}=r,o=yield Q(r);n.time("canvas to data url");let c=o.toDataURL(a,i);if(["image/png","image/jpeg"].includes(a)&&s&&ie&&Xe){const[u,l]=c.split(",");let d=0,m=!1;if(a==="image/png"){const w=Ve(l);w>=0?(d=Math.ceil((w+28)/3)*4,m=!0):d=33/3*4}else a==="image/jpeg"&&(d=18/3*4);const f=l.substring(0,d),h=l.substring(d),g=window.atob(f),p=new Uint8Array(g.length);for(let w=0;w mirroring selectedElement's bounding rect. Click (no + // drag) drops a comment pin; drag paints a magenta SVG stroke. All coords + // are stored in element-local CSS px so they survive scroll / resize and + // correlate directly with the captured PNG. + // --------------------------------------------------------------------------- + + const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click + const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it + let annotOverlayEl = null; + let annotSvgEl = null; + let annotPinsEl = null; + let annotClearChipEl = null; + let annotState = { comments: [], strokes: [] }; + let annotActive = false; + // `annotPointer` is either: + // { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin + // { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin + let annotPointer = null; + let annotEditing = null; // { idx, input, wrapEl } + let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete + + function initAnnotOverlay() { + annotOverlayEl = document.createElement('div'); + annotOverlayEl.id = PREFIX + '-annot'; + Object.assign(annotOverlayEl.style, { + position: 'fixed', top: '0', left: '0', width: '0', height: '0', + pointerEvents: 'auto', zIndex: Z.highlight + 2, + display: 'none', overflow: 'visible', + cursor: 'crosshair', touchAction: 'none', + }); + + annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + annotSvgEl.id = PREFIX + '-annot-svg'; + Object.assign(annotSvgEl.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', + // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via + // pointer-events=stroke so gaps still fall through to the overlay. + pointerEvents: 'none', overflow: 'visible', + }); + annotOverlayEl.appendChild(annotSvgEl); + + annotPinsEl = document.createElement('div'); + annotPinsEl.id = PREFIX + '-annot-pins'; + Object.assign(annotPinsEl.style, { + position: 'absolute', inset: '0', + pointerEvents: 'none', + }); + annotOverlayEl.appendChild(annotPinsEl); + + annotClearChipEl = document.createElement('div'); + annotClearChipEl.id = PREFIX + '-annot-clear'; + annotClearChipEl.dataset.annotClear = 'true'; + annotClearChipEl.textContent = 'Clear'; + Object.assign(annotClearChipEl.style, { + position: 'absolute', top: '8px', right: '8px', + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '10px', fontWeight: '500', + letterSpacing: '0.08em', textTransform: 'uppercase', + padding: '5px 12px', borderRadius: '999px', + cursor: 'pointer', pointerEvents: 'auto', + display: 'none', userSelect: 'none', + boxShadow: '0 1px 3px rgba(0,0,0,0.2)', + }); + annotOverlayEl.appendChild(annotClearChipEl); + + annotOverlayEl.addEventListener('pointerdown', onAnnotDown); + annotOverlayEl.addEventListener('pointermove', onAnnotMove); + annotOverlayEl.addEventListener('pointerup', onAnnotUp); + annotOverlayEl.addEventListener('pointercancel', onAnnotUp); + document.body.appendChild(annotOverlayEl); + } + + function updateClearChip() { + if (!annotClearChipEl) return; + const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0; + annotClearChipEl.style.display = hasAny ? 'block' : 'none'; + } + + function showAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + annotActive = true; + positionAnnotOverlay(el); + annotOverlayEl.style.display = 'block'; + } + + function hideAnnotOverlay() { + annotActive = false; + if (annotOverlayEl) annotOverlayEl.style.display = 'none'; + // Drop any in-progress edit without touching annotState — clearAnnotations + // (if the caller is exiting configure mode) handles state reset. + annotEditing = null; + } + + function positionAnnotOverlay(el) { + if (!annotOverlayEl || !el) return; + const r = el.getBoundingClientRect(); + Object.assign(annotOverlayEl.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height); + } + + function clearAnnotations() { + annotState.comments = []; + annotState.strokes = []; + if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + if (annotPinsEl) annotPinsEl.innerHTML = ''; + annotPointer = null; + annotEditing = null; + annotLastPinClick = { idx: -1, time: 0 }; + updateClearChip(); + } + + // Rebuild the SVG layer. Each stroke gets a wider invisible hit path + // beneath the visible magenta path so clicks register on thin lines. + function redrawStrokes() { + while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild); + annotState.strokes.forEach((s, idx) => { + const d = pointsToPath(s.points); + const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + hit.setAttribute('d', d); + hit.setAttribute('stroke', 'transparent'); + hit.setAttribute('stroke-width', '16'); + hit.setAttribute('stroke-linecap', 'round'); + hit.setAttribute('stroke-linejoin', 'round'); + hit.setAttribute('fill', 'none'); + hit.setAttribute('pointer-events', 'stroke'); + hit.style.cursor = 'pointer'; + hit.dataset.annotStroke = String(idx); + annotSvgEl.appendChild(hit); + const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + visible.setAttribute('d', d); + visible.setAttribute('stroke', C.brand); + visible.setAttribute('stroke-width', '3'); + visible.setAttribute('stroke-linecap', 'round'); + visible.setAttribute('stroke-linejoin', 'round'); + visible.setAttribute('fill', 'none'); + visible.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(visible); + }); + updateClearChip(); + } + + function localCoords(e) { + const rect = annotOverlayEl.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + function onAnnotDown(e) { + if (!annotActive) return; + + // 1) Clear chip → wipe all annotations + if (e.target.closest?.('[data-annot-clear]')) { + if (annotEditing) annotEditing = null; + clearAnnotations(); + renderAllPins(); + redrawStrokes(); + e.stopPropagation(); e.preventDefault(); + return; + } + + // 2) Stroke hit path → delete that stroke + const strokeHit = e.target.closest?.('[data-annot-stroke]'); + if (strokeHit) { + const idx = parseInt(strokeHit.dataset.annotStroke, 10); + if (Number.isInteger(idx)) { + annotState.strokes.splice(idx, 1); + redrawStrokes(); + } + e.stopPropagation(); e.preventDefault(); + return; + } + + // 3) Pin → drag, edit, or delete-on-double-click + const pinWrap = e.target.closest?.('[data-annot-pin]'); + if (pinWrap) { + const idx = parseInt(pinWrap.dataset.annotPin, 10); + if (!Number.isInteger(idx)) return; + // Double-click (two pointerdowns on the same pin within window) → delete. + const now = Date.now(); + if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) { + if (annotEditing && annotEditing.idx === idx) annotEditing = null; + annotState.comments.splice(idx, 1); + annotLastPinClick = { idx: -1, time: 0 }; + renderAllPins(); + e.stopPropagation(); e.preventDefault(); + return; + } + annotLastPinClick = { idx, time: now }; + // If editing a different pin, commit that edit before starting here. + if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin(); + // If already editing THIS pin and the user clicked the dot, let the + // input keep focus (don't start a drag — the click wasn't meant as one). + if (annotEditing && annotEditing.idx === idx) return; + const p = localCoords(e); + const pin = annotState.comments[idx]; + annotPointer = { + kind: 'pin', idx, + startPointer: p, + startPin: { x: pin.x, y: pin.y }, + moved: false, + }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + return; + } + + // 4) Empty area → commit any open edit, then start new annotation + if (annotEditing) { + finalizeEditingPin(); + e.stopPropagation(); e.preventDefault(); + return; + } + const p = localCoords(e); + annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null }; + try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {} + e.stopPropagation(); e.preventDefault(); + } + + function onAnnotMove(e) { + if (!annotActive || !annotPointer) return; + const p = localCoords(e); + + if (annotPointer.kind === 'pin') { + const dx = p.x - annotPointer.startPointer.x; + const dy = p.y - annotPointer.startPointer.y; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + } + const pin = annotState.comments[annotPointer.idx]; + if (!pin) { annotPointer = null; return; } + pin.x = annotPointer.startPin.x + dx; + pin.y = annotPointer.startPin.y + dy; + renderAllPins(); + e.stopPropagation(); + return; + } + + // kind === 'new' + const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0; + if (!annotPointer.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + annotPointer.moved = true; + const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + strokeEl.setAttribute('stroke', C.brand); + strokeEl.setAttribute('stroke-width', '3'); + strokeEl.setAttribute('stroke-linecap', 'round'); + strokeEl.setAttribute('stroke-linejoin', 'round'); + strokeEl.setAttribute('fill', 'none'); + strokeEl.setAttribute('pointer-events', 'none'); + annotSvgEl.appendChild(strokeEl); + annotPointer.strokeEl = strokeEl; + annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]]; + } + annotPointer.strokePoints.push([p.x, p.y]); + annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints)); + e.stopPropagation(); + } + + function onAnnotUp(e) { + if (!annotActive || !annotPointer) return; + + if (annotPointer.kind === 'pin') { + const wasDrag = annotPointer.moved; + const idx = annotPointer.idx; + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + if (wasDrag) { + // A drag is an intentional reposition; a follow-up click shouldn't be + // interpreted as a double-click-to-delete. + annotLastPinClick = { idx: -1, time: 0 }; + } else { + beginEditPin(idx); + } + e.stopPropagation(); + return; + } + + // kind === 'new' + const wasDrag = annotPointer.moved; + if (wasDrag) { + annotState.strokes.push({ points: annotPointer.strokePoints }); + // Swap the temporary preview SVG path for the full render with hit paths. + redrawStrokes(); + } else { + const idx = annotState.comments.length; + annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' }); + renderAllPins(); + beginEditPin(idx); + } + try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {} + annotPointer = null; + e.stopPropagation(); + } + + function pointsToPath(points) { + if (!points || points.length === 0) return ''; + let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1); + for (let i = 1; i < points.length; i++) { + d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1); + } + return d; + } + + function renderAllPins() { + annotPinsEl.innerHTML = ''; + annotState.comments.forEach((c, idx) => { + annotPinsEl.appendChild(buildPinElement(c, idx)); + }); + updateClearChip(); + } + + function buildPinElement(comment, idx) { + const interactive = idx >= 0; + const wrap = document.createElement('div'); + if (interactive) wrap.dataset.annotPin = String(idx); + Object.assign(wrap.style, { + position: 'absolute', + left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px', + pointerEvents: interactive ? 'auto' : 'none', + display: 'flex', alignItems: 'flex-start', gap: '6px', + cursor: interactive ? 'grab' : 'default', + touchAction: 'none', + }); + const dot = document.createElement('div'); + Object.assign(dot.style, { + width: '14px', height: '14px', borderRadius: '50%', + background: C.brand, border: '2px solid ' + C.white, + boxShadow: '0 1px 3px rgba(0,0,0,0.25)', + flexShrink: '0', + }); + wrap.appendChild(dot); + + if (comment.text) { + const bubble = document.createElement('div'); + bubble.textContent = comment.text; + Object.assign(bubble.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + marginTop: '-2px', maxWidth: '220px', + pointerEvents: 'none', whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }); + wrap.appendChild(bubble); + } + return wrap; + } + + function beginEditPin(idx) { + const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]'); + if (!wrapEl) return; + // Strip any existing bubble (but keep the dot) + wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove()); + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Note…'; + Object.assign(input.style, { + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', lineHeight: '1.4', + padding: '4px 8px', borderRadius: '3px', + border: '1px solid ' + C.brand, + outline: 'none', marginTop: '-2px', + width: '220px', pointerEvents: 'auto', + }); + const originalText = annotState.comments[idx].text || ''; + input.value = originalText; + wrapEl.appendChild(input); + annotEditing = { idx, input, wrapEl, originalText }; + input.addEventListener('keydown', onAnnotInputKey, true); + input.addEventListener('blur', () => { + // Fires on both focus-loss and programmatic blur; commit unless we + // already handled it. + if (annotEditing && annotEditing.input === input) finalizeEditingPin(); + }); + // Stop clicks/pointerdowns inside the input from bubbling to the overlay + ['pointerdown', 'click'].forEach(ev => { + input.addEventListener(ev, e => e.stopPropagation()); + }); + setTimeout(() => input.focus(), 0); + } + + function onAnnotInputKey(e) { + if (e.key === 'Enter') { + e.preventDefault(); e.stopPropagation(); + finalizeEditingPin(); + } else if (e.key === 'Escape') { + e.preventDefault(); e.stopPropagation(); + cancelEditingPin(); + } else { + // Keep arrows / backspace from hitting global handlers + e.stopPropagation(); + } + } + + function finalizeEditingPin() { + if (!annotEditing) return; + const { idx, input } = annotEditing; + const text = input.value.trim(); + annotEditing = null; + if (text) annotState.comments[idx].text = text; + else annotState.comments.splice(idx, 1); + renderAllPins(); + } + + function cancelEditingPin() { + if (!annotEditing) return; + const { idx, originalText } = annotEditing; + annotEditing = null; + // If the pin had text before this edit, revert to it. If it was a + // just-created empty pin, Escape removes it. + if (originalText) { + annotState.comments[idx].text = originalText; + } else { + annotState.comments.splice(idx, 1); + } + renderAllPins(); + } + + // Build a detached annotation subtree suitable for injection into the clone + // modern-screenshot creates. Coordinates are element-local so this slots + // straight into an element that's been made position:relative. Takes an + // explicit snapshot so it works after annotState has been cleared. + function buildAnnotationsForCapture(rect, snapshot) { + const comments = snapshot ? snapshot.comments : annotState.comments; + const strokes = snapshot ? snapshot.strokes : annotState.strokes; + if (comments.length === 0 && strokes.length === 0) return null; + const wrap = document.createElement('div'); + Object.assign(wrap.style, { + position: 'absolute', top: '0', left: '0', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', overflow: 'visible', + }); + if (strokes.length > 0) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height); + Object.assign(svg.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', overflow: 'visible', + }); + for (const s of strokes) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('stroke', C.brand); + path.setAttribute('stroke-width', '3'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('fill', 'none'); + path.setAttribute('d', pointsToPath(s.points)); + svg.appendChild(path); + } + wrap.appendChild(svg); + } + for (const c of comments) { + // idx=-1 means non-interactive; pointerEvents stay off in the clone + wrap.appendChild(buildPinElement(c, -1)); + } + return wrap; + } + // --------------------------------------------------------------------------- // Element context extraction // --------------------------------------------------------------------------- @@ -435,8 +901,10 @@ fontSize: '11px', color: C.ash, whiteSpace: 'nowrap', marginLeft: 'auto', }); + // Variants currently arrive atomically in a single file edit, so a + // per-variant counter would lie. Say what's true. status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + (arrivedVariants + 1) + ' of ' + expectedVariants + '...' + ? 'Generating ' + expectedVariants + ' variants...' : 'Done'; row.appendChild(status); @@ -857,6 +1325,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + hideShaderOverlay(); updateBarContent('cycling'); } else if (state === 'GENERATING') { updateBarContent('generating'); @@ -879,6 +1348,10 @@ positionBar(); showHighlight(selectedElement); } + if (annotActive) positionAnnotOverlay(selectedElement); + // Shader overlay (via debug P toggle or generation) is repositioned + // by its own branch below; debug no longer has a separate overlay. + if (shaderState) positionShaderOverlay(); scrollRaf = requestAnimationFrame(tick); } scrollRaf = requestAnimationFrame(tick); @@ -956,6 +1429,8 @@ } hideBar(); hideHighlight(); + hideShaderOverlay(); + hideAnnotOverlay(); stopScrollTracking(); if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } clearSession(); @@ -995,6 +1470,8 @@ if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) { hideBar(); stopScrollTracking(); + hideAnnotOverlay(); + clearAnnotations(); state = 'PICKING'; hoveredElement = null; hideHighlight(); @@ -1008,15 +1485,19 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); } function handleKeyDown(e) { + // When the annotation input is focused, let it handle its own keys. + if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; if (e.key === 'Escape') { e.preventDefault(); if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } - if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); state = 'PICKING'; return; } + if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; } if (state === 'CYCLING') { handleDiscard(); return; } if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt if (state === 'PICKING') { hideHighlight(); state = 'IDLE'; return; } @@ -1043,6 +1524,8 @@ selectedElement = hoveredElement; state = 'CONFIGURING'; showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); showBar('configure'); startScrollTracking(); return; @@ -1054,6 +1537,8 @@ } else { // CONFIGURING: re-select the new element and refresh the bar selectedElement = next; + clearAnnotations(); + showAnnotOverlay(next); showBar('configure'); startScrollTracking(); } @@ -1075,25 +1560,408 @@ const input = document.getElementById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; + // Commit any pending pin edit BEFORE we snapshot annotations. + if (annotEditing) finalizeEditingPin(); + currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; - sendEvent({ + // Flip to GENERATING immediately so the bar morphs without waiting on + // capture + upload. The event is emitted from captureAndEmit() once the + // screenshot is uploaded (or capture fails — we still emit, just without + // screenshotPath). + const elForCapture = selectedElement; + const captureRect = elForCapture.getBoundingClientRect(); + const snapshot = { + comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })), + strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })), + }; + const basePayload = { type: 'generate', id: currentSessionId, action: selectedAction, freeformPrompt: prompt || undefined, count: selectedCount, pageUrl: location.pathname, - element: extractContext(selectedElement), - }); + element: extractContext(elForCapture), + }; + if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments; + if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes; + + // Hide the interactive overlay so it doesn't linger during generation. + hideAnnotOverlay(); + clearAnnotations(); state = 'GENERATING'; showBar('generating'); saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + + captureAndEmit(elForCapture, basePayload, snapshot, captureRect); + } + + // --------------------------------------------------------------------------- + // Screenshot capture + upload + // --------------------------------------------------------------------------- + + let msLoadPromise = null; + function loadModernScreenshot() { + if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot); + if (msLoadPromise) return msLoadPromise; + msLoadPromise = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; + s.onload = () => resolve(window.modernScreenshot); + s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; + document.head.appendChild(s); + }); + return msLoadPromise; + } + + // Collect @font-face rules from every stylesheet on the page. Cross-origin + // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules + // access, so modern-screenshot can't embed them on its own — the resulting + // SVG falls back to system fonts and text re-wraps + renders with different + // weight. We fetch the raw CSS text (CORS-permitted for these providers), + // extract @font-face blocks, inline the referenced font files as base64 + // data URIs (SVGs rasterized via canvas can't fetch external resources, + // so URLs inside the SVG silently fail without this), and pass the result + // to modern-screenshot as font.cssText. + const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i; + const FONT_MIME = { + woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject', + }; + function bufferToBase64(buf) { + const bytes = new Uint8Array(buf); + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); + } + async function inlineFontUrls(cssText) { + const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g; + const urls = new Set(); + let m; + while ((m = urlRe.exec(cssText))) { + if (FONT_EXT_RE.test(m[2])) urls.add(m[2]); + } + const map = new Map(); + await Promise.all([...urls].map(async (url) => { + try { + const res = await fetch(url); + if (!res.ok) return; + const buf = await res.arrayBuffer(); + const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2'; + const mime = FONT_MIME[ext] || 'application/octet-stream'; + map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf)); + } catch { /* skip; fall through to URL */ } + })); + return cssText.replace(urlRe, (orig, q, url) => { + const data = map.get(url); + return data ? 'url(' + q + data + q + ')' : orig; + }); + } + async function collectFontCssText() { + const chunks = []; + const fontFaceRe = /@font-face\s*\{[^}]*\}/g; + for (const sheet of document.styleSheets) { + try { + const rules = sheet.cssRules; + for (const rule of rules) { + if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) { + chunks.push(rule.cssText); + } + } + } catch { + if (!sheet.href) continue; + try { + const res = await fetch(sheet.href); + if (!res.ok) continue; + const text = await res.text(); + let m2; + while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]); + } catch { /* ignore; capture is best-effort */ } + } + } + if (chunks.length === 0) return ''; + return inlineFontUrls(chunks.join('\n')); + } + + // Capture the element (with current annotations baked in) and return a PNG + // Blob. Shared between the Go flow (uploads it to the server) and the + // debug toggle (displays it as an overlay for side-by-side comparison). + async function captureElementToBlob(el, snapshot, rect) { + try { if (document.fonts?.ready) await document.fonts.ready; } catch {} + const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); + let annotNode = null; + let savedPosition = null; + if (hasAnnotations) { + const pos = getComputedStyle(el).position; + if (pos === 'static') { + savedPosition = el.style.position; + el.style.position = 'relative'; + } + annotNode = buildAnnotationsForCapture(rect, snapshot); + el.appendChild(annotNode); + } + try { + const ms = await loadModernScreenshot(); + const fontCssText = await collectFontCssText(); + return await ms.domToBlob(el, { + scale: Math.min(window.devicePixelRatio || 1, 2), + backgroundColor: getComputedStyle(document.body).backgroundColor || '#ffffff', + font: fontCssText ? { cssText: fontCssText } : undefined, + }); + } finally { + if (annotNode) annotNode.remove(); + if (savedPosition !== null) el.style.position = savedPosition; + } + } + + async function captureAndEmit(el, basePayload, snapshot, rect) { + let screenshotPath; + let blob; + try { + blob = await captureElementToBlob(el, snapshot, rect); + } catch (err) { + console.warn('[impeccable] capture failed, proceeding without screenshot:', err); + } + // Light up the shader overlay the moment capture is ready — no reason to + // wait for the upload to complete before the user sees something alive. + if (blob && state === 'GENERATING') { + showShaderOverlay(el, blob, rect); + } + if (blob) { + try { + const uploadRes = await fetch( + 'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) + + '&eventId=' + encodeURIComponent(basePayload.id), + { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob }, + ); + if (uploadRes.ok) { + const { path: p } = await uploadRes.json(); + screenshotPath = p; + } else { + console.warn('[impeccable] annotation upload failed:', uploadRes.status); + } + } catch (err) { + console.warn('[impeccable] annotation upload failed:', err); + } + } + sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); + } + + // --------------------------------------------------------------------------- + // Shader overlay — renders the captured screenshot as a WebGL texture and + // runs an editorial "ink-wash" fragment shader over it during generation. + // A single rolling band sweeps top-to-bottom, desaturating + tinting magenta + // and leaving a soft trail. Makes the wait feel like a letterpress scan + // instead of a dead spinner. + // --------------------------------------------------------------------------- + + const SHADER_VS = `attribute vec2 a_position; +attribute vec2 a_uv; +varying vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_position, 0.0, 1.0); +}`; + + const SHADER_FS = `precision highp float; +uniform sampler2D u_texture; +uniform float u_time; +uniform vec2 u_resolution; +uniform vec3 u_accent; +varying vec2 v_uv; + +// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at +// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean +// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below" +// failure that reversed-edge smoothstep would give). +float bandAt(float d, float leadW, float trailW) { + float above = smoothstep(-leadW, 0.0, d); + float below = 1.0 - smoothstep(0.0, trailW, d); + return above * below; +} + +void main() { + vec2 uv = v_uv; + // Roller sweeps top-to-bottom with small overshoot so each cycle enters + // and exits the element cleanly. + float phase = fract(u_time / 3.4); + float y = phase * 1.25 - 0.12; + float band = bandAt(uv.y - y, 0.05, 0.32); + + // Halftone cell grid (fixed ~10 px pitch). + float cellPx = 10.0; + vec2 gridUv = uv * u_resolution / cellPx; + vec2 cellId = floor(gridUv); + vec2 cellUv = fract(gridUv) - 0.5; + vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution; + vec3 cellImg = texture2D(u_texture, sampleCenter).rgb; + float luma = dot(cellImg, vec3(0.299, 0.587, 0.114)); + // Darker cells → bigger magenta dots (classic risograph halftone curve). + float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56; + float dotMask = smoothstep(radius + 0.06, radius, length(cellUv)); + vec3 paper = vec3(0.975, 0.965, 0.955); + vec3 dotLayer = mix(paper, u_accent, dotMask); + + // Blend the halftone layer in where the roller is passing; leave the + // element pristine elsewhere. + vec3 base = texture2D(u_texture, uv).rgb; + gl_FragColor = vec4(mix(base, dotLayer, band), 1.0); +}`; + + // Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350)) + const SHADER_ACCENT = [0.82, 0.16, 0.47]; + let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } + + function compileShader(gl, type, source) { + const sh = gl.createShader(type); + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(sh); + gl.deleteShader(sh); + throw new Error('shader compile failed: ' + info); + } + return sh; + } + + function positionShaderOverlay() { + if (!shaderState || !selectedElement) return; + const r = selectedElement.getBoundingClientRect(); + Object.assign(shaderState.canvas.style, { + top: r.top + 'px', left: r.left + 'px', + width: r.width + 'px', height: r.height + 'px', + }); + } + + function hideShaderOverlay() { + if (!shaderState) return; + if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); + if (shaderState.canvas) shaderState.canvas.remove(); + const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); + try { lose?.loseContext(); } catch {} + shaderState = null; + } + + async function showShaderOverlay(el, blob, rect) { + hideShaderOverlay(); + if (!blob || !el) return; + const canvas = document.createElement('canvas'); + canvas.id = PREFIX + '-shader'; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + Object.assign(canvas.style, { + position: 'fixed', + top: rect.top + 'px', left: rect.left + 'px', + width: rect.width + 'px', height: rect.height + 'px', + pointerEvents: 'none', + zIndex: Z.bar - 1, + }); + document.body.appendChild(canvas); + + const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) + || canvas.getContext('experimental-webgl'); + if (!gl) { + // WebGL unavailable — fall back to a plain overlay so the user + // still sees something meaningful during generation. + canvas.remove(); + const img = document.createElement('img'); + img.src = URL.createObjectURL(blob); + img.id = PREFIX + '-shader'; + Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' }); + document.body.appendChild(img); + shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 }; + return; + } + + let program, texture; + try { + const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS); + program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error('program link failed: ' + gl.getProgramInfoLog(program)); + } + // Full-screen quad + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + -1, -1, 0, 1, + 1, -1, 1, 1, + -1, 1, 0, 0, + -1, 1, 0, 0, + 1, -1, 1, 1, + 1, 1, 1, 0, + ]), gl.STATIC_DRAW); + const posLoc = gl.getAttribLocation(program, 'a_position'); + const uvLoc = gl.getAttribLocation(program, 'a_uv'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(uvLoc); + gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8); + } catch (err) { + console.warn('[impeccable] shader setup failed:', err); + canvas.remove(); + return; + } + + // Upload the screenshot as a texture + let bitmap; + try { + bitmap = await createImageBitmap(blob); + } catch { + // Safari fallback: go via a regular Image + const imgUrl = URL.createObjectURL(blob); + const img = new Image(); + img.src = imgUrl; + await new Promise((r, rej) => { img.onload = r; img.onerror = rej; }); + bitmap = img; + URL.revokeObjectURL(imgUrl); + } + texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap); + if (bitmap.close) bitmap.close(); + + const uTime = gl.getUniformLocation(program, 'u_time'); + const uRes = gl.getUniformLocation(program, 'u_resolution'); + const uAccent = gl.getUniformLocation(program, 'u_accent'); + const uTex = gl.getUniformLocation(program, 'u_texture'); + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; + function frame() { + if (!shaderState) return; + const elapsed = (performance.now() - shaderState.startTime) / 1000; + const t = shaderState.reduced ? 0.0 : elapsed; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.useProgram(program); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.uniform1i(uTex, 0); + gl.uniform1f(uTime, t); + gl.uniform2f(uRes, canvas.width, canvas.height); + gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]); + gl.drawArrays(gl.TRIANGLES, 0, 6); + shaderState.rafId = requestAnimationFrame(frame); + } + frame(); } function handleAccept() { @@ -2732,6 +3600,7 @@ function init() { initHighlight(); + initAnnotOverlay(); initBar(); initActionPicker(); initGlobalBar(); diff --git a/source/skills/impeccable/scripts/live-server.mjs b/source/skills/impeccable/scripts/live-server.mjs index 109aa768e..97163b255 100644 --- a/source/skills/impeccable/scripts/live-server.mjs +++ b/source/skills/impeccable/scripts/live-server.mjs @@ -18,7 +18,6 @@ import { randomUUID } from 'node:crypto'; import { spawn, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './design-parser.mjs'; @@ -56,8 +55,13 @@ const state = { pendingEvents: [], // browser events waiting for agent poll pendingPolls: [], // agent poll callbacks waiting for browser events exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots }; +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + function enqueueEvent(event) { if (state.pendingPolls.length > 0) { state.pendingPolls.shift()(event); @@ -134,6 +138,10 @@ function validateEvent(msg) { if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; return null; case 'accept': if (!msg.id) return 'accept: missing id'; @@ -175,6 +183,83 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { return; } + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + // --- Health --- if (p === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -250,10 +335,11 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) { if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } const absPath = path.resolve(process.cwd(), filePath); if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } - try { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(fs.readFileSync(absPath, 'utf-8')); - } catch { res.writeHead(404); res.end('File not found'); } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); return; } @@ -411,6 +497,9 @@ let httpServer = null; function shutdown() { try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } for (const res of state.sseClients) { try { res.end(); } catch {} } state.sseClients.clear(); for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); @@ -442,12 +531,14 @@ Options: --help Show this help Endpoints: - /live.js Browser script (element picker + variant cycling) - /detect.js Detection overlay (backwards compatible) - /events SSE stream (server→browser) + POST (browser→server) - /poll Long-poll for agent CLI - /source Raw source file reader (no-HMR fallback) - /health Health check`); + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); process.exit(0); } @@ -531,6 +622,12 @@ try { state.token = randomUUID(); const portArg = args.find(a => a.startsWith('--port=')); state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); const { detectScript, liveScript } = loadBrowserScripts(); const liveScriptWithToken = diff --git a/source/skills/impeccable/scripts/modern-screenshot.umd.js b/source/skills/impeccable/scripts/modern-screenshot.umd.js new file mode 100644 index 000000000..a9c5208f6 --- /dev/null +++ b/source/skills/impeccable/scripts/modern-screenshot.umd.js @@ -0,0 +1,14 @@ +(function(y,v){typeof exports=="object"&&typeof module!="undefined"?v(exports):typeof define=="function"&&define.amd?define(["exports"],v):(y=typeof globalThis!="undefined"?globalThis:y||self,v(y.modernScreenshot={}))})(this,function(y){"use strict";var rr=Object.defineProperty,nr=Object.defineProperties;var or=Object.getOwnPropertyDescriptors;var Z=Object.getOwnPropertySymbols;var xe=Object.prototype.hasOwnProperty,Me=Object.prototype.propertyIsEnumerable;var Oe=Math.pow,Le=(y,v,N)=>v in y?rr(y,v,{enumerable:!0,configurable:!0,writable:!0,value:N}):y[v]=N,D=(y,v)=>{for(var N in v||(v={}))xe.call(v,N)&&Le(y,N,v[N]);if(Z)for(var N of Z(v))Me.call(v,N)&&Le(y,N,v[N]);return y},M=(y,v)=>nr(y,or(v));var je=(y,v)=>{var N={};for(var R in y)xe.call(y,R)&&v.indexOf(R)<0&&(N[R]=y[R]);if(y!=null&&Z)for(var R of Z(y))v.indexOf(R)<0&&Me.call(y,R)&&(N[R]=y[R]);return N};var C=(y,v,N)=>new Promise((R,O)=>{var X=P=>{try{q(N.next(P))}catch(W){O(W)}},j=P=>{try{q(N.throw(P))}catch(W){O(W)}},q=P=>P.done?R(P.value):Promise.resolve(P.value).then(X,j);q((N=N.apply(y,v)).next())});var Be;function v(e,t){return e[13]=1,e[14]=t>>8,e[15]=t&255,e[16]=t>>8,e[17]=t&255,e}const N=112,R=72,O=89,X=115;let j;function q(){const e=new Int32Array(256);for(let t=0;t<256;t++){let r=t;for(let n=0;n<8;n++)r=r&1?3988292384^r>>>1:r>>>1;e[t]=r}return e}function P(e){let t=-1;j||(j=q());for(let r=0;r>>8;return t^-1}function W(e){const t=e.length-1;for(let r=t;r>=4;r--)if(e[r-4]===9&&e[r-3]===N&&e[r-2]===R&&e[r-1]===O&&e[r]===X)return r-3;return 0}function ae(e,t,r=!1){const n=new Uint8Array(13);t*=39.3701,n[0]=N,n[1]=R,n[2]=O,n[3]=X,n[4]=t>>>24,n[5]=t>>>16,n[6]=t>>>8,n[7]=t&255,n[8]=n[4],n[9]=n[5],n[10]=n[6],n[11]=n[7],n[12]=1;const i=P(n),a=new Uint8Array(4);if(a[0]=i>>>24,a[1]=i>>>16,a[2]=i>>>8,a[3]=i&255,r){const s=W(e);return e.set(n,s),e.set(a,s+13),e}else{const s=new Uint8Array(4);s[0]=0,s[1]=0,s[2]=0,s[3]=9;const o=new Uint8Array(54);return o.set(e,0),o.set(s,33),o.set(n,37),o.set(a,50),o}}const qe="AAlwSFlz",We="AAAJcEhZ",He="AAAACXBI";function Ve(e){let t=e.indexOf(qe);return t===-1&&(t=e.indexOf(We)),t===-1&&(t=e.indexOf(He)),t}const se="[modern-screenshot]",U=typeof window!="undefined",ze=U&&"Worker"in window,ie=U&&"atob"in window,Xe=U&&"btoa"in window,ee=U?(Be=window.navigator)==null?void 0:Be.userAgent:"",le=ee.includes("Chrome"),G=ee.includes("AppleWebKit")&&!le,te=ee.includes("Firefox"),Ge=e=>e&&"__CONTEXT__"in e,Ye=e=>e.constructor.name==="CSSFontFaceRule",Je=e=>e.constructor.name==="CSSImportRule",Ke=e=>e.constructor.name==="CSSLayerBlockRule",I=e=>e.nodeType===1,H=e=>typeof e.className=="object",ce=e=>e.tagName==="image",Qe=e=>e.tagName==="use",V=e=>I(e)&&typeof e.style!="undefined"&&!H(e),Ze=e=>e.nodeType===8,et=e=>e.nodeType===3,$=e=>e.tagName==="IMG",Y=e=>e.tagName==="VIDEO",tt=e=>e.tagName==="CANVAS",rt=e=>e.tagName==="TEXTAREA",nt=e=>e.tagName==="INPUT",ot=e=>e.tagName==="STYLE",at=e=>e.tagName==="SCRIPT",st=e=>e.tagName==="SELECT",it=e=>e.tagName==="SLOT",lt=e=>e.tagName==="IFRAME",ct=(...e)=>console.warn(se,...e);function ut(e){var r;const t=(r=e==null?void 0:e.createElement)==null?void 0:r.call(e,"canvas");return t&&(t.height=t.width=1),!!t&&"toDataURL"in t&&!!t.toDataURL("image/webp").includes("image/webp")}const re=e=>e.startsWith("data:");function ue(e,t){if(e.match(/^[a-z]+:\/\//i))return e;if(U&&e.match(/^\/\//))return window.location.protocol+e;if(e.match(/^[a-z]+:/i)||!U)return e;const r=J().implementation.createHTMLDocument(),n=r.createElement("base"),i=r.createElement("a");return r.head.appendChild(n),r.body.appendChild(i),t&&(n.href=t),i.href=e,i.href}function J(e){var t;return(t=e&&I(e)?e==null?void 0:e.ownerDocument:e)!=null?t:window.document}const K="http://www.w3.org/2000/svg";function fe(e,t,r){const n=J(r).createElementNS(K,"svg");return n.setAttributeNS(null,"width",e.toString()),n.setAttributeNS(null,"height",t.toString()),n.setAttributeNS(null,"viewBox",`0 0 ${e} ${t}`),n}function de(e,t){let r=new XMLSerializer().serializeToString(e);return t&&(r=r.replace(/[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu,"")),`data:image/svg+xml;charset=utf-8,${encodeURIComponent(r)}`}function ft(e,t="image/png",r=1){return C(this,null,function*(){try{return yield new Promise((n,i)=>{e.toBlob(a=>{a?n(a):i(new Error("Blob is null"))},t,r)})}catch(n){if(ie)return dt(e.toDataURL(t,r));throw n}})}function dt(e){var o,c;const[t,r]=e.split(","),n=(c=(o=t.match(/data:(.+);/))==null?void 0:o[1])!=null?c:void 0,i=window.atob(r),a=i.length,s=new Uint8Array(a);for(let u=0;u{const i=new FileReader;i.onload=()=>r(i.result),i.onerror=()=>n(i.error),i.onabort=()=>n(new Error(`Failed read blob to ${t}`)),t==="dataUrl"?i.readAsDataURL(e):t==="arrayBuffer"&&i.readAsArrayBuffer(e)})}const gt=e=>ge(e,"dataUrl"),mt=e=>ge(e,"arrayBuffer");function _(e,t){const r=J(t).createElement("img");return r.decoding="sync",r.loading="eager",r.src=e,r}function L(e,t){return new Promise(r=>{const{timeout:n,ownerDocument:i,onError:a,onWarn:s}=t!=null?t:{},o=typeof e=="string"?_(e,J(i)):e;let c=null,u=null;function l(){r(o),c&&clearTimeout(c),u==null||u()}if(n&&(c=setTimeout(l,n)),Y(o)){const d=o.currentSrc||o.src;if(!d)return o.poster?L(o.poster,t).then(r):l();if(o.readyState>=2)return l();const m=l,f=h=>{s==null||s("Failed video load",d,h),a==null||a(h),l()};u=()=>{o.removeEventListener("loadeddata",m),o.removeEventListener("error",f)},o.addEventListener("loadeddata",m,{once:!0}),o.addEventListener("error",f,{once:!0})}else{const d=ce(o)?o.href.baseVal:o.currentSrc||o.src;if(!d)return l();const m=()=>C(this,null,function*(){if($(o)&&"decode"in o)try{yield o.decode()}catch(h){s==null||s("Failed to decode image, trying to render anyway",o.dataset.originalSrc||d,h)}l()}),f=h=>{s==null||s("Failed image load",o.dataset.originalSrc||d,h),l()};if($(o)&&o.complete)return m();u=()=>{o.removeEventListener("load",m),o.removeEventListener("error",f)},o.addEventListener("load",m,{once:!0}),o.addEventListener("error",f,{once:!0})}})}function me(e,t){return C(this,null,function*(){V(e)&&($(e)||Y(e)?yield L(e,t):yield Promise.all(["img","video"].flatMap(r=>Array.from(e.querySelectorAll(r)).map(n=>L(n,t)))))})}const he=function(){let t=0;const r=()=>`0000${(Math.random()*Oe(36,4)<<0).toString(36)}`.slice(-4);return()=>(t+=1,`u${r()}${t}`)}();function we(e){return e==null?void 0:e.split(",").map(t=>t.trim().replace(/"|'/g,"").toLowerCase()).filter(Boolean)}let pe=0;function ht(e){const t=`${se}[#${pe}]`;return pe++,{time:r=>e&&console.time(`${t} ${r}`),timeEnd:r=>e&&console.timeEnd(`${t} ${r}`),warn:(...r)=>e&&ct(...r)}}function wt(e){return{cache:e?"no-cache":"force-cache"}}function k(e,t){return C(this,null,function*(){return Ge(e)?e:ye(e,M(D({},t),{autoDestruct:!0}))})}function ye(e,t){return C(this,null,function*(){var f,h,g,p,E;const{scale:r=1,workerUrl:n,workerNumber:i=1}=t||{},a=!!(t!=null&&t.debug),s=(f=t==null?void 0:t.features)!=null?f:!0,o=(h=e.ownerDocument)!=null?h:U?window.document:void 0,c=(p=(g=e.ownerDocument)==null?void 0:g.defaultView)!=null?p:U?window:void 0,u=new Map,l=M(D({width:0,height:0,quality:1,type:"image/png",scale:r,backgroundColor:null,style:null,filter:null,maximumCanvasSize:0,timeout:3e4,progress:null,debug:a,fetch:D({requestInit:wt((E=t==null?void 0:t.fetch)==null?void 0:E.bypassingCache),placeholderImage:"data:image/png;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",bypassingCache:!1},t==null?void 0:t.fetch),fetchFn:null,font:{},drawImageInterval:100,workerUrl:null,workerNumber:i,onCloneEachNode:null,onCloneNode:null,onEmbedNode:null,onCreateForeignObjectSvg:null,includeStyleProperties:null,autoDestruct:!1},t),{__CONTEXT__:!0,log:ht(a),node:e,ownerDocument:o,ownerWindow:c,dpi:r===1?null:96*r,svgStyleElement:be(o),svgDefsElement:o==null?void 0:o.createElementNS(K,"defs"),svgStyles:new Map,defaultComputedStyles:new Map,workers:[...Array.from({length:ze&&n&&i?i:0})].map(()=>{try{const b=new Worker(n);return b.onmessage=w=>C(this,null,function*(){var A,F,B,$e;const{url:S,result:T}=w.data;T?(F=(A=u.get(S))==null?void 0:A.resolve)==null||F.call(A,T):($e=(B=u.get(S))==null?void 0:B.reject)==null||$e.call(B,new Error(`Error receiving message from worker: ${S}`))}),b.onmessageerror=w=>{var T,A;const{url:S}=w.data;(A=(T=u.get(S))==null?void 0:T.reject)==null||A.call(T,new Error(`Error receiving message from worker: ${S}`))},b}catch(b){return l.log.warn("Failed to new Worker",b),null}}).filter(Boolean),fontFamilies:new Map,fontCssTexts:new Map,acceptOfImage:`${[ut(o)&&"image/webp","image/svg+xml","image/*","*/*"].filter(Boolean).join(",")};q=0.8`,requests:u,drawImageCount:0,tasks:[],features:s,isEnable:b=>{var w,S;return b==="restoreScrollPosition"?typeof s=="boolean"?!1:(w=s[b])!=null?w:!1:typeof s=="boolean"?s:(S=s[b])!=null?S:!0},shadowRoots:[]});l.log.time("wait until load"),yield me(e,{timeout:l.timeout,onWarn:l.log.warn}),l.log.timeEnd("wait until load");const{width:d,height:m}=pt(e,l);return l.width=d,l.height=m,l})}function be(e){if(!e)return;const t=e.createElement("style"),r=t.ownerDocument.createTextNode(` +.______background-clip--text { + background-clip: text; + -webkit-background-clip: text; +} +`);return t.appendChild(r),t}function pt(e,t){let{width:r,height:n}=t;if(I(e)&&(!r||!n)){const i=e.getBoundingClientRect();r=r||i.width||Number(e.getAttribute("width"))||0,n=n||i.height||Number(e.getAttribute("height"))||0}return{width:r,height:n}}function yt(e,t){return C(this,null,function*(){const{log:r,timeout:n,drawImageCount:i,drawImageInterval:a}=t;r.time("image to canvas");const s=yield L(e,{timeout:n,onWarn:t.log.warn}),{canvas:o,context2d:c}=bt(e.ownerDocument,t),u=()=>{try{c==null||c.drawImage(s,0,0,o.width,o.height)}catch(l){t.log.warn("Failed to drawImage",l)}};if(u(),t.isEnable("fixSvgXmlDecode"))for(let l=0;l{setTimeout(()=>{c==null||c.clearRect(0,0,o.width,o.height),u(),d()},l+a)});return t.drawImageCount=0,r.timeEnd("image to canvas"),o})}function bt(e,t){const{width:r,height:n,scale:i,backgroundColor:a,maximumCanvasSize:s}=t,o=e.createElement("canvas");o.width=Math.floor(r*i),o.height=Math.floor(n*i),o.style.width=`${r}px`,o.style.height=`${n}px`,s&&(o.width>s||o.height>s)&&(o.width>s&&o.height>s?o.width>o.height?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s):o.width>s?(o.height*=s/o.width,o.width=s):(o.width*=s/o.height,o.height=s));const c=o.getContext("2d");return c&&a&&(c.fillStyle=a,c.fillRect(0,0,o.width,o.height)),{canvas:o,context2d:c}}function Se(e,t){if(e.ownerDocument)try{const a=e.toDataURL();if(a!=="data:,")return _(a,e.ownerDocument)}catch(a){t.log.warn("Failed to clone canvas",a)}const r=e.cloneNode(!1),n=e.getContext("2d"),i=r.getContext("2d");try{return n&&i&&i.putImageData(n.getImageData(0,0,e.width,e.height),0,0),r}catch(a){t.log.warn("Failed to clone canvas",a)}return r}function St(e,t){var r;try{if((r=e==null?void 0:e.contentDocument)!=null&&r.documentElement)return ne(e.contentDocument.documentElement,t)}catch(n){t.log.warn("Failed to clone iframe",n)}return e.cloneNode(!1)}function Et(e){const t=e.cloneNode(!1);return e.currentSrc&&e.currentSrc!==e.src&&(t.src=e.currentSrc,t.srcset=""),t.loading==="lazy"&&(t.loading="eager"),t}function Ct(e,t){return C(this,null,function*(){if(e.ownerDocument&&!e.currentSrc&&e.poster)return _(e.poster,e.ownerDocument);const r=e.cloneNode(!1);r.crossOrigin="anonymous",e.currentSrc&&e.currentSrc!==e.src&&(r.src=e.currentSrc);const n=r.ownerDocument;if(n){let i=!0;if(yield L(r,{onError:()=>i=!1,onWarn:t.log.warn}),!i)return e.poster?_(e.poster,e.ownerDocument):r;r.currentTime=e.currentTime,yield new Promise(s=>{r.addEventListener("seeked",s,{once:!0})});const a=n.createElement("canvas");a.width=e.offsetWidth,a.height=e.offsetHeight;try{const s=a.getContext("2d");s&&s.drawImage(r,0,0,a.width,a.height)}catch(s){return t.log.warn("Failed to clone video",s),e.poster?_(e.poster,e.ownerDocument):r}return Se(a,t)}return r})}function Tt(e,t){return tt(e)?Se(e,t):lt(e)?St(e,t):$(e)?Et(e):Y(e)?Ct(e,t):e.cloneNode(!1)}function vt(e){let t=e.sandbox;if(!t){const{ownerDocument:r}=e;try{r&&(t=r.createElement("iframe"),t.id=`__SANDBOX__${he()}`,t.width="0",t.height="0",t.style.visibility="hidden",t.style.position="fixed",r.body.appendChild(t),t.srcdoc='',e.sandbox=t)}catch(n){e.log.warn("Failed to getSandBox",n)}}return t}const At=["width","height","-webkit-text-fill-color"],Nt=["stroke","fill"];function Ee(e,t,r){const{defaultComputedStyles:n}=r,i=e.nodeName.toLowerCase(),a=H(e)&&i!=="svg",s=a?Nt.map(g=>[g,e.getAttribute(g)]).filter(([,g])=>g!==null):[],o=[a&&"svg",i,s.map((g,p)=>`${g}=${p}`).join(","),t].filter(Boolean).join(":");if(n.has(o))return n.get(o);const c=vt(r),u=c==null?void 0:c.contentWindow;if(!u)return new Map;const l=u==null?void 0:u.document;let d,m;a?(d=l.createElementNS(K,"svg"),m=d.ownerDocument.createElementNS(d.namespaceURI,i),s.forEach(([g,p])=>{m.setAttributeNS(null,g,p)}),d.appendChild(m)):d=m=l.createElement(i),m.textContent=" ",l.body.appendChild(d);const f=u.getComputedStyle(m,t),h=new Map;for(let g=f.length,p=0;pn.set(d,l));function s(c){const u=e.getPropertyValue(c),l=e.getPropertyPriority(c),d=c.lastIndexOf("-"),m=d>-1?c.substring(0,d):void 0;if(m){let f=a.get(m);f||(f=new Map,a.set(m,f)),f.set(c,[u,l])}t.get(c)===u&&!l||(m?i.push(m):n.set(c,[u,l]))}return n}function Rt(e,t,r,n){var d,m,f,h;const{ownerWindow:i,includeStyleProperties:a,currentParentNodeStyle:s}=n,o=t.style,c=i.getComputedStyle(e),u=Ee(e,null,n);s==null||s.forEach((g,p)=>{u.delete(p)});const l=Ce(c,u,a);l.delete("transition-property"),l.delete("all"),l.delete("d"),l.delete("content"),r&&(l.delete("position"),l.delete("margin-top"),l.delete("margin-right"),l.delete("margin-bottom"),l.delete("margin-left"),l.delete("margin-block-start"),l.delete("margin-block-end"),l.delete("margin-inline-start"),l.delete("margin-inline-end"),l.set("box-sizing",["border-box",""])),((d=l.get("background-clip"))==null?void 0:d[0])==="text"&&t.classList.add("______background-clip--text"),le&&(l.has("font-kerning")||l.set("font-kerning",["normal",""]),(((m=l.get("overflow-x"))==null?void 0:m[0])==="hidden"||((f=l.get("overflow-y"))==null?void 0:f[0])==="hidden")&&((h=l.get("text-overflow"))==null?void 0:h[0])==="ellipsis"&&e.scrollWidth===e.clientWidth&&l.set("text-overflow",["clip",""]));for(let g=o.length,p=0;p{o.setProperty(E,g,p)}),l}function It(e,t){(rt(e)||nt(e)||st(e))&&t.setAttribute("value",e.value)}const kt=["::before","::after"],Dt=["::-webkit-scrollbar","::-webkit-scrollbar-button","::-webkit-scrollbar-thumb","::-webkit-scrollbar-track","::-webkit-scrollbar-track-piece","::-webkit-scrollbar-corner","::-webkit-resizer"];function Pt(e,t,r,n,i){const{ownerWindow:a,svgStyleElement:s,svgStyles:o,currentNodeStyle:c}=n;if(!s||!a)return;function u(l){var w;const d=a.getComputedStyle(e,l);let m=d.getPropertyValue("content");if(!m||m==="none")return;i==null||i(m),m=m.replace(/(')|(")|(counter\(.+\))/g,"");const f=[he()],h=Ee(e,l,n);c==null||c.forEach((S,T)=>{h.delete(T)});const g=Ce(d,h,n.includeStyleProperties);g.delete("content"),g.delete("-webkit-locale"),((w=g.get("background-clip"))==null?void 0:w[0])==="text"&&t.classList.add("______background-clip--text");const p=[`content: '${m}';`];if(g.forEach(([S,T],A)=>{p.push(`${A}: ${S}${T?" !important":""};`)}),p.length===1)return;try{t.className=[t.className,...f].join(" ")}catch(S){n.log.warn("Failed to copyPseudoClass",S);return}const E=p.join(` + `);let b=o.get(E);b||(b=[],o.set(E,b)),b.push(`.${f[0]}${l}`)}kt.forEach(u),r&&Dt.forEach(u)}const Te=new Set(["symbol"]);function ve(e,t,r,n,i){return C(this,null,function*(){if(I(r)&&(ot(r)||at(r))||n.filter&&!n.filter(r))return;Te.has(t.nodeName)||Te.has(r.nodeName)?n.currentParentNodeStyle=void 0:n.currentParentNodeStyle=n.currentNodeStyle;const a=yield ne(r,n,!1,i);n.isEnable("restoreScrollPosition")&&Ut(e,a),t.appendChild(a)})}function Ae(e,t,r,n){return C(this,null,function*(){var a;let i=e.firstChild;I(e)&&e.shadowRoot&&(i=(a=e.shadowRoot)==null?void 0:a.firstChild,r.shadowRoots.push(e.shadowRoot));for(let s=i;s;s=s.nextSibling)if(!Ze(s))if(I(s)&&it(s)&&typeof s.assignedNodes=="function"){const o=s.assignedNodes();for(let c=0;ce.clientHeight||e.scrollWidth>e.clientWidth)}const p=(d=h.get("text-transform"))==null?void 0:d[0],E=we((m=h.get("font-family"))==null?void 0:m[0]),b=E?w=>{p==="uppercase"?w=w.toUpperCase():p==="lowercase"?w=w.toLowerCase():p==="capitalize"&&(w=w[0].toUpperCase()+w.substring(1)),E.forEach(S=>{let T=s.get(S);T||s.set(S,T=new Set),w.split("").forEach(A=>T.add(A))})}:void 0;return Pt(e,f,g,t,b),It(e,f),Y(e)||(yield Ae(e,f,t,b)),yield o==null?void 0:o(f),f}const c=e.cloneNode(!1);return yield Ae(e,c,t),yield o==null?void 0:o(c),c})}function Ne(e){if(e.ownerDocument=void 0,e.ownerWindow=void 0,e.svgStyleElement=void 0,e.svgDefsElement=void 0,e.svgStyles.clear(),e.defaultComputedStyles.clear(),e.sandbox){try{e.sandbox.remove()}catch(t){e.log.warn("Failed to destroyContext",t)}e.sandbox=void 0}e.workers=[],e.fontFamilies.clear(),e.fontCssTexts.clear(),e.requests.clear(),e.tasks=[],e.shadowRoots=[]}function Bt(e){const o=e,{url:t,timeout:r,responseType:n}=o,i=je(o,["url","timeout","responseType"]),a=new AbortController,s=r?setTimeout(()=>a.abort(),r):void 0;return fetch(t,D({signal:a.signal},i)).then(c=>{if(!c.ok)throw new Error("Failed fetch, not 2xx response",{cause:c});switch(n){case"arrayBuffer":return c.arrayBuffer();case"dataUrl":return c.blob().then(gt);case"text":default:return c.text()}}).finally(()=>clearTimeout(s))}function z(e,t){const{url:r,requestType:n="text",responseType:i="text",imageDom:a}=t;let s=r;const{timeout:o,acceptOfImage:c,requests:u,fetchFn:l,fetch:{requestInit:d,bypassingCache:m,placeholderImage:f},font:h,workers:g,fontFamilies:p}=e;n==="image"&&(G||te)&&e.drawImageCount++;let E=u.get(r);if(!E){m&&m instanceof RegExp&&m.test(s)&&(s+=(/\?/.test(s)?"&":"?")+new Date().getTime());const b=n.startsWith("font")&&h&&h.minify,w=new Set;b&&n.split(";")[1].split(",").forEach(F=>{p.has(F)&&p.get(F).forEach(B=>w.add(B))});const S=b&&w.size,T=D({url:s,timeout:o,responseType:S?"arrayBuffer":i,headers:n==="image"?{accept:c}:void 0},d);E={type:n,resolve:void 0,reject:void 0,response:null},E.response=C(this,null,function*(){if(l&&n==="image"){const A=yield l(r);if(A)return A}return!G&&r.startsWith("http")&&g.length?new Promise((A,F)=>{g[u.size&g.length-1].postMessage(D({rawUrl:r},T)),E.resolve=A,E.reject=F}):Bt(T)}).catch(A=>{if(u.delete(r),n==="image"&&f)return e.log.warn("Failed to fetch image base64, trying to use placeholder image",s),typeof f=="string"?f:f(a);throw A}),u.set(r,E)}return E.response}function Re(e,t,r,n){return C(this,null,function*(){if(!Ie(e))return e;for(const[i,a]of $t(e,t))try{const s=yield z(r,{url:a,requestType:n?"image":"text",responseType:"dataUrl"});e=e.replace(Lt(i),`$1${s}$3`)}catch(s){r.log.warn("Failed to fetch css data url",i,s)}return e})}function Ie(e){return/url\((['"]?)([^'"]+?)\1\)/.test(e)}const ke=/url\((['"]?)([^'"]+?)\1\)/g;function $t(e,t){const r=[];return e.replace(ke,(n,i,a)=>(r.push([a,ue(a,t)]),n)),r.filter(([n])=>!re(n))}function Lt(e){const t=e.replace(/([.*+?^${}()|\[\]\/\\])/g,"\\$1");return new RegExp(`(url\\(['"]?)(${t})(['"]?\\))`,"g")}const xt=["background-image","border-image-source","-webkit-border-image","-webkit-mask-image","list-style-image"];function Mt(e,t){return xt.map(r=>{const n=e.getPropertyValue(r);return!n||n==="none"?null:((G||te)&&t.drawImageCount++,Re(n,null,t,!0).then(i=>{!i||n===i||e.setProperty(r,i,e.getPropertyPriority(r))}))}).filter(Boolean)}function Ot(e,t){if($(e)){const r=e.currentSrc||e.src;if(!re(r))return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.srcset="",e.dataset.originalSrc=r,e.src=n||"")})];(G||te)&&t.drawImageCount++}else if(H(e)&&!re(e.href.baseVal)){const r=e.href.baseVal;return[z(t,{url:r,imageDom:e,requestType:"image",responseType:"dataUrl"}).then(n=>{n&&(e.dataset.originalSrc=r,e.href.baseVal=n||"")})]}return[]}function jt(e,t){var o;const{ownerDocument:r,svgDefsElement:n}=t,i=(o=e.getAttribute("href"))!=null?o:e.getAttribute("xlink:href");if(!i)return[];const[a,s]=i.split("#");if(s){const c=`#${s}`,u=t.shadowRoots.reduce((l,d)=>l!=null?l:d.querySelector(`svg ${c}`),r==null?void 0:r.querySelector(`svg ${c}`));if(a&&e.setAttribute("href",c),n!=null&&n.querySelector(c))return[];if(u)return n==null||n.appendChild(u.cloneNode(!0)),[];if(a)return[z(t,{url:a,responseType:"text"}).then(l=>{n==null||n.insertAdjacentHTML("beforeend",l)})]}return[]}function De(e,t){const{tasks:r}=t;I(e)&&(($(e)||ce(e))&&r.push(...Ot(e,t)),Qe(e)&&r.push(...jt(e,t))),V(e)&&r.push(...Mt(e.style,t)),e.childNodes.forEach(n=>{De(n,t)})}function qt(e,t){return C(this,null,function*(){const{ownerDocument:r,svgStyleElement:n,fontFamilies:i,fontCssTexts:a,tasks:s,font:o}=t;if(!(!r||!n||!i.size))if(o&&o.cssText){const c=Ue(o.cssText,t);n.appendChild(r.createTextNode(`${c} +`))}else{const c=Array.from(r.styleSheets).filter(f=>{try{return"cssRules"in f&&!!f.cssRules.length}catch(h){return t.log.warn(`Error while reading CSS rules from ${f.href}`,h),!1}}),u=r.implementation.createHTMLDocument(""),l=u.createElement("style");u.head.appendChild(l);const d=l.sheet;yield Promise.all(c.flatMap(f=>Array.from(f.cssRules).map(h=>C(this,null,function*(){if(Je(h)){const g=h.href;let p="";try{p=yield z(t,{url:g,requestType:"text",responseType:"text"})}catch(b){t.log.warn(`Error fetch remote css import from ${g}`,b)}const E=p.replace(ke,(b,w,S)=>b.replace(S,ue(S,g)));for(const b of Ht(E))try{d.insertRule(b,d.cssRules.length)}catch(w){t.log.warn("Error inserting rule from remote css import",{rule:b,error:w})}}})))),d.cssRules.length&&c.push(d);const m=[];c.forEach(f=>{oe(f.cssRules,m)}),m.filter(f=>{var h;return Ye(f)&&Ie(f.style.getPropertyValue("src"))&&((h=we(f.style.getPropertyValue("font-family")))==null?void 0:h.some(g=>i.has(g)))}).forEach(f=>{const h=f,g=a.get(h.cssText);g?n.appendChild(r.createTextNode(`${g} +`)):s.push(Re(h.cssText,h.parentStyleSheet?h.parentStyleSheet.href:null,t).then(p=>{p=Ue(p,t),a.set(h.cssText,p),n.appendChild(r.createTextNode(`${p} +`))}))})}})}const Wt=/(\/\*[\s\S]*?\*\/)/g,Pe=/((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi;function Ht(e){if(e==null)return[];const t=[];let r=e.replace(Wt,"");for(;;){const a=Pe.exec(r);if(!a)break;t.push(a[0])}r=r.replace(Pe,"");const n=/@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi,i=new RegExp("((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})","gi");for(;;){let a=n.exec(r);if(a)i.lastIndex=n.lastIndex;else if(a=i.exec(r),a)n.lastIndex=i.lastIndex;else break;t.push(a[0])}return t}const Vt=/url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g,zt=/src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g;function Ue(e,t){const{font:r}=t,n=r?r==null?void 0:r.preferredFormat:void 0;return n?e.replace(zt,i=>{for(;;){const[a,,s]=Vt.exec(i)||[];if(!s)return"";if(s===n)return`src: ${a};`}}):e}function oe(e,t=[]){for(const r of Array.from(e))Ke(r)?t.push(...oe(r.cssRules)):"cssRules"in r?oe(r.cssRules,t):t.push(r);return t}const Xt=/\bx?link:?href\s*=\s*["'](?!data:)[^"']+["']/i;function Gt(e){return Xt.test(e.innerHTML)}function _e(e,t){return C(this,null,function*(){const r=yield k(e,t);if(I(r.node)&&H(r.node)&&!Gt(r.node))return r.node;const{ownerDocument:n,log:i,tasks:a,svgStyleElement:s,svgDefsElement:o,svgStyles:c,font:u,progress:l,autoDestruct:d,onCloneNode:m,onEmbedNode:f,onCreateForeignObjectSvg:h}=r;i.time("clone node");const g=yield ne(r.node,r,!0);if(s&&n){let S="";c.forEach((T,A)=>{S+=`${T.join(`, +`)} { + ${A} +} +`}),s.appendChild(n.createTextNode(S))}i.timeEnd("clone node"),yield m==null?void 0:m(g),u!==!1&&I(g)&&(i.time("embed web font"),yield qt(g,r),i.timeEnd("embed web font")),i.time("embed node"),De(g,r);const p=a.length;let E=0;const b=()=>C(this,null,function*(){for(;;){const S=a.pop();if(!S)break;try{yield S}catch(T){r.log.warn("Failed to run task",T)}l==null||l(++E,p)}});l==null||l(E,p),yield Promise.all([...Array.from({length:4})].map(b)),i.timeEnd("embed node"),yield f==null?void 0:f(g);const w=Yt(g,r);return o&&w.insertBefore(o,w.children[0]),s&&w.insertBefore(s,w.children[0]),d&&Ne(r),yield h==null?void 0:h(w),w})}function Yt(e,t){const{width:r,height:n}=t,i=fe(r,n,e.ownerDocument),a=i.ownerDocument.createElementNS(i.namespaceURI,"foreignObject");return a.setAttributeNS(null,"x","0%"),a.setAttributeNS(null,"y","0%"),a.setAttributeNS(null,"width","100%"),a.setAttributeNS(null,"height","100%"),a.append(e),i.appendChild(a),i}function Q(e,t){return C(this,null,function*(){var s;const r=yield k(e,t),n=yield _e(r),i=de(n,r.isEnable("removeControlCharacter"));r.autoDestruct||(r.svgStyleElement=be(r.ownerDocument),r.svgDefsElement=(s=r.ownerDocument)==null?void 0:s.createElementNS(K,"defs"),r.svgStyles.clear());const a=_(i,n.ownerDocument);return yield yt(a,r)})}function Jt(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,type:i,quality:a,dpi:s}=r,o=yield Q(r);n.time("canvas to blob");const c=yield ft(o,i,a);if(["image/png","image/jpeg"].includes(i)&&s){const u=yield mt(c.slice(0,33));let l=new Uint8Array(u);return i==="image/png"?l=ae(l,s):i==="image/jpeg"&&(l=v(l,s)),n.timeEnd("canvas to blob"),new Blob([l,c.slice(33)],{type:i})}return n.timeEnd("canvas to blob"),c})}function x(e,t){return C(this,null,function*(){const r=yield k(e,t),{log:n,quality:i,type:a,dpi:s}=r,o=yield Q(r);n.time("canvas to data url");let c=o.toDataURL(a,i);if(["image/png","image/jpeg"].includes(a)&&s&&ie&&Xe){const[u,l]=c.split(",");let d=0,m=!1;if(a==="image/png"){const w=Ve(l);w>=0?(d=Math.ceil((w+28)/3)*4,m=!0):d=33/3*4}else a==="image/jpeg"&&(d=18/3*4);const f=l.substring(0,d),h=l.substring(d),g=window.atob(f),p=new Uint8Array(g.length);for(let w=0;w { assert.ok(true, 'Server rejected request for missing file'); } }); + + it('/modern-screenshot.js serves the vendored UMD build', async () => { + const res = await fetch(`http://localhost:${server.port}/modern-screenshot.js`); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'application/javascript'); + const text = await res.text(); + // Sanity: the UMD build self-registers as window.modernScreenshot. + assert.ok(text.includes('modernScreenshot')); + }); + + it('POST /annotation rejects invalid token', async () => { + const res = await fetch(`http://localhost:${server.port}/annotation?token=wrong&eventId=abc`, { + method: 'POST', headers: { 'Content-Type': 'image/png' }, body: new Uint8Array([0x89, 0x50, 0x4e, 0x47]), + }); + assert.equal(res.status, 401); + }); + + it('POST /annotation rejects invalid eventId', async () => { + const res = await fetch(`http://localhost:${server.port}/annotation?token=${server.token}&eventId=has%20spaces`, { + method: 'POST', headers: { 'Content-Type': 'image/png' }, body: new Uint8Array([0x89]), + }); + assert.equal(res.status, 400); + }); + + it('POST /annotation rejects non-PNG content-type', async () => { + const res = await fetch(`http://localhost:${server.port}/annotation?token=${server.token}&eventId=abc`, { + method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: new Uint8Array([0x89]), + }); + assert.equal(res.status, 415); + }); + + it('POST /annotation writes PNG to session dir and returns path', async () => { + const eventId = 'test-' + Math.random().toString(36).slice(2, 10); + // Minimal valid PNG header + IEND chunk (enough to prove we wrote bytes) + const png = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]); + const res = await fetch(`http://localhost:${server.port}/annotation?token=${server.token}&eventId=${eventId}`, { + method: 'POST', headers: { 'Content-Type': 'image/png' }, body: png, + }); + assert.equal(res.status, 200); + const data = await res.json(); + assert.equal(data.ok, true); + assert.ok(data.path.endsWith(eventId + '.png')); + const written = readFileSync(data.path); + assert.equal(written.length, png.length); + }); + + it('POST /events accepts generate with optional annotation fields', async () => { + // Drain any queued events from previous tests + let drained; + do { + const r = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=100`); + drained = await r.json(); + } while (drained.type !== 'timeout'); + + const postRes = await fetch(`http://localhost:${server.port}/events`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: server.token, type: 'generate', + id: 'annot-1', action: 'polish', count: 2, + element: { outerHTML: '
x
', tagName: 'div' }, + screenshotPath: '/tmp/fake.png', + comments: [{ x: 10, y: 20, text: 'tighten this' }], + strokes: [{ points: [[0, 0], [10, 10]] }], + }), + }); + assert.equal(postRes.status, 200); + + const pollRes = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=2000`); + const event = await pollRes.json(); + assert.equal(event.id, 'annot-1'); + assert.equal(event.screenshotPath, '/tmp/fake.png'); + assert.equal(event.comments.length, 1); + assert.equal(event.strokes.length, 1); + }); + + it('POST /events rejects generate with malformed annotation fields', async () => { + const postRes = await fetch(`http://localhost:${server.port}/events`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: server.token, type: 'generate', + id: 'annot-bad', action: 'polish', count: 2, + element: { outerHTML: '
x
', tagName: 'div' }, + comments: 'not-an-array', + }), + }); + assert.equal(postRes.status, 400); + const data = await postRes.json(); + assert.ok(data.error.includes('comments')); + }); });