Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/live-browser.js
1/**2* Impeccable Live Variant Mode — Browser Script3*4* Injected into the user's page via <script src="http://localhost:PORT/live.js">.5* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__6* before this code.7*8* UI: a single floating bar that morphs between three states —9* configure (pick action + go), generating (progressive dots), and cycling10* (prev/next + accept/discard). Feels like Spotlight, not a modal.11*/12(function () {13'use strict';14if (typeof window === 'undefined') return;1516// Guard against double-init. Bun's HTML loader may process the <script> tag17// and create a bundled copy alongside the external load, or HMR may re-execute.18// Check BEFORE reading token/port to catch all cases.19if (window.__IMPECCABLE_LIVE_INIT__) return;20window.__IMPECCABLE_LIVE_INIT__ = true;2122const TOKEN = window.__IMPECCABLE_TOKEN__;23const PORT = window.__IMPECCABLE_PORT__;24if (!TOKEN || !PORT) {25window.__IMPECCABLE_LIVE_INIT__ = false; // reset so the real load can init26return;27}2829// ---------------------------------------------------------------------------30// Design tokens31// ---------------------------------------------------------------------------3233// Brand magenta is pinned to the site token (--color-accent in main.css)34// so Accept / knobs / cycle-dots match the site's accent, not a washed35// theme-adjusted one.36const C = {37brand: 'oklch(60% 0.25 350)',38brandHov: 'oklch(52% 0.25 350)',39brandSoft: 'oklch(60% 0.25 350 / 0.15)',40ink: 'oklch(15% 0.01 350)',41ash: 'oklch(55% 0 0)',42paper: 'oklch(98% 0.005 350 / 0.92)',43paperSolid:'oklch(98% 0.005 350)',44mist: 'oklch(90% 0.01 350 / 0.6)',45white: 'oklch(99% 0 0)',46};47const FONT = 'system-ui, -apple-system, sans-serif';48const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';49// z-index: detect overlays use 99999, so our UI must be above them50const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };51const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint52const PREFIX = 'impeccable-live';53const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({54prefix: PREFIX,55storage: localStorage,56idFactory: () => crypto.randomUUID().replace(/-/g, '').slice(0, 8),57});58if (!sessionState) {59console.error('[impeccable] live-browser-session.js was not loaded. Live mode cannot start safely.');60window.__IMPECCABLE_LIVE_INIT__ = false;61return;62}63const HIGHLIGHT_TRANSITION =64'top 140ms ' + EASE +65', left 140ms ' + EASE +66', width 140ms ' + EASE +67', height 140ms ' + EASE +68', opacity 150ms ease';69const TOOLTIP_TRANSITION =70'top 140ms ' + EASE + ', left 140ms ' + EASE + ', opacity 150ms ease';7172const SKIP_TAGS = new Set([73'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',74]);7576// SVG icons stack above each chip label. All strokes use currentColor so the77// icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,78// 1.5 stroke — visually consistent with the Foundation grid on the homepage.79const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';80const ICONS = {81impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,82bolder: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>`,83quieter: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>`,84distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,85polish: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>`,86typeset: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>`,87colorize: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>`,88layout: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>`,89adapt: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>`,90animate: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>`,91delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,92overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,93};9495const ACTIONS = [96{ value: 'impeccable', label: 'Freeform' },97{ value: 'bolder', label: 'Bolder' },98{ value: 'quieter', label: 'Quieter' },99{ value: 'distill', label: 'Distill' },100{ value: 'polish', label: 'Polish' },101{ value: 'typeset', label: 'Typeset' },102{ value: 'colorize', label: 'Colorize' },103{ value: 'layout', label: 'Layout' },104{ value: 'adapt', label: 'Adapt' },105{ value: 'animate', label: 'Animate' },106{ value: 'delight', label: 'Delight' },107{ value: 'overdrive', label: 'Overdrive' },108];109110// ---------------------------------------------------------------------------111// State112// ---------------------------------------------------------------------------113114let state = 'IDLE';115let hoveredElement = null;116let selectedElement = null;117let currentSessionId = null;118let expectedVariants = 0;119let arrivedVariants = 0;120let visibleVariant = 0;121let variantObserver = null;122let hasProjectContext = false;123let selectedAction = 'impeccable';124let selectedCount = 3;125const browserOwner = sessionState.owner;126let checkpointTimer = null;127128// Scroll lock — holds window.scrollY at a fixed value while the session is129// active, so HMR DOM patches and variant swaps can't drift the page. See130// startScrollLock / stopScrollLock below.131let scrollLockObserver = null;132let scrollLockTargetY = null;133let scrollLockRaf = null;134let scrollLockAbort = null;135136// Dedicated key for scroll position — SEPARATE from LS_KEY so that137// saveSession's state updates don't clobber a carefully-captured scrollY.138// (Previously: saveSession wrote scrollY alongside state, so every call139// during resume overwrote the pre-reload value with whatever the browser140// had landed on, typically 0.)141function writeScrollY(y) { sessionState.writeScrollY(y); }142function readScrollY() { return sessionState.readScrollY(); }143function clearScrollY() { sessionState.clearScrollY(); }144145// Pre-empt the browser: apply manual scroll restoration and jump to the146// saved scrollY at script-parse time. Retries on fonts.ready and load147// are essential: scrollTo(y) clamps to the current document.scrollHeight,148// which is often hundreds of pixels short of the final value until149// async-loaded fonts swap in and reflow.150try {151history.scrollRestoration = 'manual';152const savedY = readScrollY();153if (savedY != null) {154const apply = () => {155if (Math.abs(window.scrollY - savedY) > 0.5) {156console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY });157window.scrollTo(0, savedY);158}159};160apply();161if (document.fonts?.ready) document.fonts.ready.then(apply).catch(() => {});162window.addEventListener('load', apply, { once: true });163}164} catch {}165166// UI refs167let highlightEl = null;168let tooltipEl = null;169let barEl = null;170let pickerEl = null;171let toastEl = null;172let scrollRaf = null;173174// ---------------------------------------------------------------------------175// Helpers176// ---------------------------------------------------------------------------177178function own(el) {179return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]'));180}181182function pickable(el) {183if (!el || el.nodeType !== 1) return false;184if (SKIP_TAGS.has(el.tagName.toLowerCase())) return false;185if (own(el)) return false;186const r = el.getBoundingClientRect();187return r.width >= 20 && r.height >= 20;188}189190function desc(el) {191if (!el) return '';192let s = el.tagName.toLowerCase();193if (el.id) s += '#' + el.id;194else if (el.classList.length) s += '.' + [...el.classList].slice(0, 2).join('.');195return s;196}197198function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }199200// Modal-aware chrome: keep our floating UI clickable inside Radix /201// Headless UI / vaul portals.202//203// Two host-page behaviors break us when the picked element lives inside a204// modal dialog:205//206// 1. Modal scroll-lock disables outside pointer events. Radix's207// `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`208// while a modal is open and only restores `auto` on the layer. Our209// chrome inherits `none` from <body> and becomes unclickable.210// 2. The dialog's outside-interaction handler (Radix's211// `usePointerDownOutside`) listens at document level and dismisses212// the dialog whenever a `pointerdown` lands outside the layer node.213// Our chrome is a sibling of <body>, so Radix classifies our clicks214// as outside and tears the dialog down mid-task.215//216// We can't reliably re-parent our chrome into the dialog subtree (z-index217// stacking, scroll containers, theming all become host-page concerns), so218// we defang both behaviors at our root:219//220// - `pointer-events: auto !important` overrides the inherited `none`.221// - Stop `pointerdown` / `mousedown` propagation so the document-level222// dismiss listener never fires for our clicks.223// - Stop `focusin` propagation so any focus shifts inside our chrome224// don't read as "focus moved outside the dialog" to focus traps.225//226// Click events still bubble normally — only the early pointer/focus227// signals that drive outside-interaction detection are silenced.228function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {229if (!rootEl) return;230if (setPointerEvents) {231rootEl.style.setProperty('pointer-events', 'auto', 'important');232}233const stop = (e) => e.stopPropagation();234rootEl.addEventListener('pointerdown', stop);235rootEl.addEventListener('mousedown', stop);236rootEl.addEventListener('focusin', stop);237}238239// ---------------------------------------------------------------------------240// Highlight overlay241// ---------------------------------------------------------------------------242243function initHighlight() {244highlightEl = document.createElement('div');245highlightEl.id = PREFIX + '-highlight';246Object.assign(highlightEl.style, {247position: 'fixed', top: '0', left: '0', width: '0', height: '0',248border: '2px solid ' + C.brand, borderRadius: '3px',249pointerEvents: 'none', zIndex: Z.highlight, boxSizing: 'border-box',250transition: HIGHLIGHT_TRANSITION,251display: 'none', opacity: '0',252});253document.body.appendChild(highlightEl);254255tooltipEl = document.createElement('div');256tooltipEl.id = PREFIX + '-tooltip';257Object.assign(tooltipEl.style, {258position: 'fixed',259background: C.ink, color: C.white,260fontFamily: MONO, fontSize: '10px', fontWeight: '500',261padding: '2px 6px', borderRadius: '3px',262zIndex: Z.highlight + 1, pointerEvents: 'none',263whiteSpace: 'nowrap', display: 'none',264letterSpacing: '0.02em',265transition: TOOLTIP_TRANSITION,266});267document.body.appendChild(tooltipEl);268}269270function showHighlight(el) {271if (!el || !highlightEl) return;272const r = el.getBoundingClientRect();273const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px';274const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px';275const tipTop = r.top - 20;276const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px';277const tipX = Math.max(4, r.left) + 'px';278tooltipEl.textContent = desc(el);279280const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0';281if (hiWasHidden) {282// Snap to first target without animating from (0,0), then fade in.283highlightEl.style.transition = 'none';284Object.assign(highlightEl.style, { top, left, width, height, display: 'block' });285tooltipEl.style.transition = 'none';286Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' });287void highlightEl.offsetWidth;288highlightEl.style.transition = HIGHLIGHT_TRANSITION;289highlightEl.style.opacity = '1';290tooltipEl.style.transition = TOOLTIP_TRANSITION;291tooltipEl.style.opacity = '1';292} else {293Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' });294Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' });295}296}297298function hideHighlight() {299if (highlightEl) { highlightEl.style.opacity = '0'; highlightEl.style.display = 'none'; }300if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; }301}302303// ---------------------------------------------------------------------------304// Annotation overlay (comment pins + magenta strokes)305//306// Active while state === 'CONFIGURING'. The overlay is a fixed-positioned307// sibling of <body> mirroring selectedElement's bounding rect. Click (no308// drag) drops a comment pin; drag paints a magenta SVG stroke. All coords309// are stored in element-local CSS px so they survive scroll / resize and310// correlate directly with the captured PNG.311// ---------------------------------------------------------------------------312313const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click314const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it315let annotOverlayEl = null;316let annotSvgEl = null;317let annotPinsEl = null;318let annotClearChipEl = null;319let annotState = { comments: [], strokes: [] };320let annotActive = false;321// `annotPointer` is either:322// { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin323// { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin324let annotPointer = null;325let annotEditing = null; // { idx, input, wrapEl }326let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete327328function initAnnotOverlay() {329annotOverlayEl = document.createElement('div');330annotOverlayEl.id = PREFIX + '-annot';331Object.assign(annotOverlayEl.style, {332position: 'fixed', top: '0', left: '0', width: '0', height: '0',333pointerEvents: 'auto', zIndex: Z.highlight + 2,334display: 'none', overflow: 'visible',335cursor: 'crosshair', touchAction: 'none',336});337338annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');339annotSvgEl.id = PREFIX + '-annot-svg';340Object.assign(annotSvgEl.style, {341position: 'absolute', top: '0', left: '0',342width: '100%', height: '100%',343// The SVG itself doesn't absorb clicks; individual hit-paths opt-in via344// pointer-events=stroke so gaps still fall through to the overlay.345pointerEvents: 'none', overflow: 'visible',346});347annotOverlayEl.appendChild(annotSvgEl);348349annotPinsEl = document.createElement('div');350annotPinsEl.id = PREFIX + '-annot-pins';351Object.assign(annotPinsEl.style, {352position: 'absolute', inset: '0',353pointerEvents: 'none',354});355annotOverlayEl.appendChild(annotPinsEl);356357annotClearChipEl = document.createElement('div');358annotClearChipEl.id = PREFIX + '-annot-clear';359annotClearChipEl.dataset.annotClear = 'true';360annotClearChipEl.textContent = 'Clear';361Object.assign(annotClearChipEl.style, {362position: 'absolute', top: '8px', right: '8px',363background: C.ink, color: C.white,364fontFamily: FONT, fontSize: '10px', fontWeight: '500',365letterSpacing: '0.08em', textTransform: 'uppercase',366padding: '5px 12px', borderRadius: '999px',367cursor: 'pointer', pointerEvents: 'auto',368display: 'none', userSelect: 'none',369boxShadow: '0 1px 3px rgba(0,0,0,0.2)',370});371annotOverlayEl.appendChild(annotClearChipEl);372373annotOverlayEl.addEventListener('pointerdown', onAnnotDown);374annotOverlayEl.addEventListener('pointermove', onAnnotMove);375annotOverlayEl.addEventListener('pointerup', onAnnotUp);376annotOverlayEl.addEventListener('pointercancel', onAnnotUp);377document.body.appendChild(annotOverlayEl);378// Modal-host friendliness: pointer-events is already 'auto' on this379// overlay; we only need to silence the host's outside-interaction380// listeners. Don't override pointer-events here (the overlay toggles381// visibility via display:none, which is fine).382defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });383}384385function updateClearChip() {386if (!annotClearChipEl) return;387const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0;388annotClearChipEl.style.display = hasAny ? 'block' : 'none';389}390391function showAnnotOverlay(el) {392if (!annotOverlayEl || !el) return;393annotActive = true;394positionAnnotOverlay(el);395annotOverlayEl.style.display = 'block';396}397398function hideAnnotOverlay() {399annotActive = false;400if (annotOverlayEl) annotOverlayEl.style.display = 'none';401// Drop any in-progress edit without touching annotState — clearAnnotations402// (if the caller is exiting configure mode) handles state reset.403annotEditing = null;404}405406function positionAnnotOverlay(el) {407if (!annotOverlayEl || !el) return;408const r = el.getBoundingClientRect();409Object.assign(annotOverlayEl.style, {410top: r.top + 'px', left: r.left + 'px',411width: r.width + 'px', height: r.height + 'px',412});413annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height);414}415416function clearAnnotations() {417annotState.comments = [];418annotState.strokes = [];419if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild);420if (annotPinsEl) annotPinsEl.innerHTML = '';421annotPointer = null;422annotEditing = null;423annotLastPinClick = { idx: -1, time: 0 };424updateClearChip();425}426427// Rebuild the SVG layer. Each stroke gets a wider invisible hit path428// beneath the visible magenta path so clicks register on thin lines.429function redrawStrokes() {430while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild);431annotState.strokes.forEach((s, idx) => {432const d = pointsToPath(s.points);433const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path');434hit.setAttribute('d', d);435hit.setAttribute('stroke', 'transparent');436hit.setAttribute('stroke-width', '16');437hit.setAttribute('stroke-linecap', 'round');438hit.setAttribute('stroke-linejoin', 'round');439hit.setAttribute('fill', 'none');440hit.setAttribute('pointer-events', 'stroke');441hit.style.cursor = 'pointer';442hit.dataset.annotStroke = String(idx);443annotSvgEl.appendChild(hit);444const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path');445visible.setAttribute('d', d);446visible.setAttribute('stroke', C.brand);447visible.setAttribute('stroke-width', '3');448visible.setAttribute('stroke-linecap', 'round');449visible.setAttribute('stroke-linejoin', 'round');450visible.setAttribute('fill', 'none');451visible.setAttribute('pointer-events', 'none');452annotSvgEl.appendChild(visible);453});454updateClearChip();455}456457function localCoords(e) {458const rect = annotOverlayEl.getBoundingClientRect();459return { x: e.clientX - rect.left, y: e.clientY - rect.top };460}461462function onAnnotDown(e) {463if (!annotActive) return;464465// 1) Clear chip → wipe all annotations466if (e.target.closest?.('[data-annot-clear]')) {467if (annotEditing) annotEditing = null;468clearAnnotations();469renderAllPins();470redrawStrokes();471e.stopPropagation(); e.preventDefault();472return;473}474475// 2) Stroke hit path → delete that stroke476const strokeHit = e.target.closest?.('[data-annot-stroke]');477if (strokeHit) {478const idx = parseInt(strokeHit.dataset.annotStroke, 10);479if (Number.isInteger(idx)) {480annotState.strokes.splice(idx, 1);481redrawStrokes();482}483e.stopPropagation(); e.preventDefault();484return;485}486487// 3) Pin → drag, edit, or delete-on-double-click488const pinWrap = e.target.closest?.('[data-annot-pin]');489if (pinWrap) {490const idx = parseInt(pinWrap.dataset.annotPin, 10);491if (!Number.isInteger(idx)) return;492// Double-click (two pointerdowns on the same pin within window) → delete.493const now = Date.now();494if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) {495if (annotEditing && annotEditing.idx === idx) annotEditing = null;496annotState.comments.splice(idx, 1);497annotLastPinClick = { idx: -1, time: 0 };498renderAllPins();499e.stopPropagation(); e.preventDefault();500return;501}502annotLastPinClick = { idx, time: now };503// If editing a different pin, commit that edit before starting here.504if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();505// If already editing THIS pin and the user clicked the dot, let the506// input keep focus (don't start a drag — the click wasn't meant as one).507if (annotEditing && annotEditing.idx === idx) return;508const p = localCoords(e);509const pin = annotState.comments[idx];510annotPointer = {511kind: 'pin', idx,512startPointer: p,513startPin: { x: pin.x, y: pin.y },514moved: false,515};516try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {}517e.stopPropagation(); e.preventDefault();518return;519}520521// 4) Empty area → commit any open edit, then start new annotation522if (annotEditing) {523finalizeEditingPin();524e.stopPropagation(); e.preventDefault();525return;526}527const p = localCoords(e);528annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null };529try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {}530e.stopPropagation(); e.preventDefault();531}532533function onAnnotMove(e) {534if (!annotActive || !annotPointer) return;535const p = localCoords(e);536537if (annotPointer.kind === 'pin') {538const dx = p.x - annotPointer.startPointer.x;539const dy = p.y - annotPointer.startPointer.y;540if (!annotPointer.moved) {541if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;542annotPointer.moved = true;543}544const pin = annotState.comments[annotPointer.idx];545if (!pin) { annotPointer = null; return; }546pin.x = annotPointer.startPin.x + dx;547pin.y = annotPointer.startPin.y + dy;548renderAllPins();549e.stopPropagation();550return;551}552553// kind === 'new'554const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0;555if (!annotPointer.moved) {556if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;557annotPointer.moved = true;558const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');559strokeEl.setAttribute('stroke', C.brand);560strokeEl.setAttribute('stroke-width', '3');561strokeEl.setAttribute('stroke-linecap', 'round');562strokeEl.setAttribute('stroke-linejoin', 'round');563strokeEl.setAttribute('fill', 'none');564strokeEl.setAttribute('pointer-events', 'none');565annotSvgEl.appendChild(strokeEl);566annotPointer.strokeEl = strokeEl;567annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]];568}569annotPointer.strokePoints.push([p.x, p.y]);570annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints));571e.stopPropagation();572}573574function onAnnotUp(e) {575if (!annotActive || !annotPointer) return;576577if (annotPointer.kind === 'pin') {578const wasDrag = annotPointer.moved;579const idx = annotPointer.idx;580try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {}581annotPointer = null;582if (wasDrag) {583// A drag is an intentional reposition; a follow-up click shouldn't be584// interpreted as a double-click-to-delete.585annotLastPinClick = { idx: -1, time: 0 };586} else {587beginEditPin(idx);588}589e.stopPropagation();590return;591}592593// kind === 'new'594const wasDrag = annotPointer.moved;595if (wasDrag) {596annotState.strokes.push({ points: annotPointer.strokePoints });597// Swap the temporary preview SVG path for the full render with hit paths.598redrawStrokes();599} else {600const idx = annotState.comments.length;601annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' });602renderAllPins();603beginEditPin(idx);604}605try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {}606annotPointer = null;607e.stopPropagation();608}609610function pointsToPath(points) {611if (!points || points.length === 0) return '';612let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1);613for (let i = 1; i < points.length; i++) {614d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1);615}616return d;617}618619function renderAllPins() {620annotPinsEl.innerHTML = '';621annotState.comments.forEach((c, idx) => {622annotPinsEl.appendChild(buildPinElement(c, idx));623});624updateClearChip();625}626627function buildPinElement(comment, idx) {628const interactive = idx >= 0;629const wrap = document.createElement('div');630if (interactive) wrap.dataset.annotPin = String(idx);631Object.assign(wrap.style, {632position: 'absolute',633left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px',634pointerEvents: interactive ? 'auto' : 'none',635display: 'flex', alignItems: 'flex-start', gap: '6px',636cursor: interactive ? 'grab' : 'default',637touchAction: 'none',638});639const dot = document.createElement('div');640Object.assign(dot.style, {641width: '14px', height: '14px', borderRadius: '50%',642background: C.brand, border: '2px solid ' + C.white,643boxShadow: '0 1px 3px rgba(0,0,0,0.25)',644flexShrink: '0',645});646wrap.appendChild(dot);647648if (comment.text) {649const bubble = document.createElement('div');650bubble.textContent = comment.text;651Object.assign(bubble.style, {652background: C.ink, color: C.white,653fontFamily: FONT, fontSize: '12px', lineHeight: '1.4',654padding: '4px 8px', borderRadius: '3px',655marginTop: '-2px', maxWidth: '220px',656pointerEvents: 'none', whiteSpace: 'pre-wrap',657wordBreak: 'break-word',658});659wrap.appendChild(bubble);660}661return wrap;662}663664function beginEditPin(idx) {665const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]');666if (!wrapEl) return;667// Strip any existing bubble (but keep the dot)668wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove());669const input = document.createElement('input');670input.type = 'text';671input.placeholder = 'Note…';672Object.assign(input.style, {673background: C.ink, color: C.white,674fontFamily: FONT, fontSize: '12px', lineHeight: '1.4',675padding: '4px 8px', borderRadius: '3px',676border: '1px solid ' + C.brand,677outline: 'none', marginTop: '-2px',678width: '220px', pointerEvents: 'auto',679});680const originalText = annotState.comments[idx].text || '';681input.value = originalText;682wrapEl.appendChild(input);683annotEditing = { idx, input, wrapEl, originalText };684input.addEventListener('keydown', onAnnotInputKey, true);685input.addEventListener('blur', () => {686// Fires on both focus-loss and programmatic blur; commit unless we687// already handled it.688if (annotEditing && annotEditing.input === input) finalizeEditingPin();689});690// Stop clicks/pointerdowns inside the input from bubbling to the overlay691['pointerdown', 'click'].forEach(ev => {692input.addEventListener(ev, e => e.stopPropagation());693});694setTimeout(() => input.focus(), 0);695}696697function onAnnotInputKey(e) {698if (e.key === 'Enter') {699e.preventDefault(); e.stopPropagation();700finalizeEditingPin();701} else if (e.key === 'Escape') {702e.preventDefault(); e.stopPropagation();703cancelEditingPin();704} else {705// Keep arrows / backspace from hitting global handlers706e.stopPropagation();707}708}709710function finalizeEditingPin() {711if (!annotEditing) return;712const { idx, input } = annotEditing;713const text = input.value.trim();714annotEditing = null;715if (text) annotState.comments[idx].text = text;716else annotState.comments.splice(idx, 1);717renderAllPins();718}719720function cancelEditingPin() {721if (!annotEditing) return;722const { idx, originalText } = annotEditing;723annotEditing = null;724// If the pin had text before this edit, revert to it. If it was a725// just-created empty pin, Escape removes it.726if (originalText) {727annotState.comments[idx].text = originalText;728} else {729annotState.comments.splice(idx, 1);730}731renderAllPins();732}733734// Build a detached annotation subtree suitable for injection into the clone735// modern-screenshot creates. Coordinates are element-local so this slots736// straight into an element that's been made position:relative. Takes an737// explicit snapshot so it works after annotState has been cleared.738function buildAnnotationsForCapture(rect, snapshot) {739const comments = snapshot ? snapshot.comments : annotState.comments;740const strokes = snapshot ? snapshot.strokes : annotState.strokes;741if (comments.length === 0 && strokes.length === 0) return null;742const wrap = document.createElement('div');743Object.assign(wrap.style, {744position: 'absolute', top: '0', left: '0',745width: rect.width + 'px', height: rect.height + 'px',746pointerEvents: 'none', overflow: 'visible',747});748if (strokes.length > 0) {749const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');750svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height);751Object.assign(svg.style, {752position: 'absolute', top: '0', left: '0',753width: '100%', height: '100%', overflow: 'visible',754});755for (const s of strokes) {756const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');757path.setAttribute('stroke', C.brand);758path.setAttribute('stroke-width', '3');759path.setAttribute('stroke-linecap', 'round');760path.setAttribute('stroke-linejoin', 'round');761path.setAttribute('fill', 'none');762path.setAttribute('d', pointsToPath(s.points));763svg.appendChild(path);764}765wrap.appendChild(svg);766}767for (const c of comments) {768// idx=-1 means non-interactive; pointerEvents stay off in the clone769wrap.appendChild(buildPinElement(c, -1));770}771return wrap;772}773774// ---------------------------------------------------------------------------775// Element context extraction776// ---------------------------------------------------------------------------777778function extractContext(el) {779const cs = getComputedStyle(el);780const r = el.getBoundingClientRect();781const props = {};782for (const sheet of document.styleSheets) {783try {784for (const rule of sheet.cssRules) {785if (rule.style) for (let i = 0; i < rule.style.length; i++) {786const p = rule.style[i];787if (p.startsWith('--') && !props[p]) {788const v = cs.getPropertyValue(p).trim();789if (v) props[p] = v;790}791}792}793} catch { /* cross-origin */ }794}795return {796tagName: el.tagName.toLowerCase(), id: el.id || null,797classes: [...el.classList],798textContent: (el.textContent || '').slice(0, 500),799outerHTML: el.outerHTML.slice(0, 10000),800computedStyles: {801'font-family': cs.fontFamily, 'font-size': cs.fontSize,802'font-weight': cs.fontWeight, 'line-height': cs.lineHeight,803'color': cs.color, 'background': cs.background,804'background-color': cs.backgroundColor,805'padding': cs.padding, 'margin': cs.margin,806'display': cs.display, 'position': cs.position,807'gap': cs.gap, 'border-radius': cs.borderRadius,808'box-shadow': cs.boxShadow,809},810cssCustomProperties: props,811parentContext: el.parentElement812? '<' + el.parentElement.tagName.toLowerCase()813+ (el.parentElement.id ? ' id="' + el.parentElement.id + '"' : '')814+ (el.parentElement.className ? ' class="' + el.parentElement.className + '"' : '')815+ '>'816: null,817boundingRect: { width: Math.round(r.width), height: Math.round(r.height) },818};819}820821// ---------------------------------------------------------------------------822// The Bar — one floating element, three modes823// ---------------------------------------------------------------------------824825// Contextual-bar palette. Cached at init so every build*Row reads a826// consistent set of colors; detectPageTheme runs once rather than on every827// phase transition.828let BP = null;829830// Bar shadow variants. The default projects down + subtle around. When831// the Tune popover opens below the bar, a downward shadow lands on the832// dark popover and reads as a bright ghost line. We swap to UP-only while833// tune is open below so the popover's top edge is clean.834const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';835const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';836const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;837838function initBar() {839BP = barPaletteForTheme(detectPageTheme());840barEl = document.createElement('div');841barEl.id = PREFIX + '-bar';842Object.assign(barEl.style, {843position: 'fixed', zIndex: Z.bar,844display: 'none', opacity: '0',845transform: 'translateY(6px)',846transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,847background: BP.surface,848backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',849border: '1px solid ' + BP.hairline,850borderRadius: '10px',851boxShadow: BAR_SHADOW_DEFAULT,852transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,853fontFamily: FONT, fontSize: '13px', color: BP.text,854padding: '6px',855maxWidth: '520px', minWidth: '320px',856});857document.body.appendChild(barEl);858defangOutsideHandlers(barEl);859}860861function positionBar() {862if (!barEl || !selectedElement) return;863const r = selectedElement.getBoundingClientRect();864const barH = barEl.offsetHeight || 44;865const barW = barEl.offsetWidth || 380;866const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room867const GAP = 8;868869// Prefer below the element; fall back to above; if neither fits (element870// taller than viewport), pin to a stable viewport anchor so the bar871// doesn't teleport between top and bottom as the user scrolls.872let top;873const belowTop = r.bottom + GAP;874const aboveTop = r.top - barH - GAP;875if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {876top = belowTop;877} else if (aboveTop >= GAP) {878top = aboveTop;879} else {880top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;881}882883let left = r.left + (r.width - barW) / 2;884if (left < GAP) left = GAP;885if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;886Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });887}888889function showBar(mode) {890barEl.innerHTML = '';891if (mode === 'configure') barEl.appendChild(buildConfigureRow());892else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());893else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());894barEl.style.display = 'block';895positionBar();896requestAnimationFrame(() => {897barEl.style.opacity = '1';898barEl.style.transform = 'translateY(0)';899});900}901902function hideBar() {903if (!barEl) return;904barEl.style.opacity = '0';905barEl.style.transform = 'translateY(6px)';906setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);907hideActionPicker();908closeTunePopover();909}910911function updateBarContent(mode) {912if (!barEl || barEl.style.display === 'none') return;913barEl.innerHTML = '';914// Reset bar styling to the theme-aware palette915barEl.style.background = BP.surface;916barEl.style.border = '1px solid ' + BP.hairline;917if (mode === 'configure') barEl.appendChild(buildConfigureRow());918else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());919else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());920else if (mode === 'saving') barEl.appendChild(buildSavingRow());921else if (mode === 'confirmed') {922barEl.appendChild(buildConfirmedRow());923barEl.style.background = 'oklch(95% 0.05 145)';924barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)';925}926}927928// --- Configure row ---929930function buildConfigureRow() {931const row = el('div', {932display: 'flex', alignItems: 'center', gap: '4px',933});934935// Action pill936const pill = el('button', {937display: 'inline-flex', alignItems: 'center', gap: '4px',938padding: '5px 10px', borderRadius: '6px',939background: BP.mark, color: BP.markText,940fontFamily: FONT, fontSize: '12px', fontWeight: '500',941border: 'none', cursor: 'pointer',942transition: 'background 0.12s ease, transform 0.1s ease',943whiteSpace: 'nowrap', flexShrink: '0',944});945pill.textContent = actionLabel() + ' \u25BE';946pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);947pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);948pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');949pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');950pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });951row.appendChild(pill);952953// Freeform input. Focus state shows an accent-colored border only —954// an earlier version tinted the background with `BP.accentSoft`, which955// composited against the dark bar surface to a murky purple where the956// browser's default placeholder gray was unreadable. Placeholder color957// is set explicitly via a one-shot stylesheet keyed off this input's id958// so it picks up the bar's `textDim` token in both themes.959const input = document.createElement('input');960input.id = PREFIX + '-input';961input.type = 'text';962input.placeholder = selectedAction === 'impeccable' ? 'describe what you want...' : 'refine further (optional)...';963Object.assign(input.style, {964flex: '1', minWidth: '0',965padding: '5px 8px', borderRadius: '6px',966border: '1px solid transparent', background: 'transparent',967fontFamily: FONT, fontSize: '12px', color: BP.text,968outline: 'none',969transition: 'border-color 0.15s ease',970});971if (!document.getElementById(PREFIX + '-input-style')) {972const s = document.createElement('style');973s.id = PREFIX + '-input-style';974s.textContent =975'#' + PREFIX + '-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }';976document.head.appendChild(s);977}978input.addEventListener('focus', () => {979input.style.borderColor = BP.accent;980});981input.addEventListener('blur', () => {982input.style.borderColor = 'transparent';983});984input.addEventListener('keydown', (e) => {985if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; }986if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; }987// Let arrow keys pass through to the element picker when the input is empty988if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !input.value) return;989e.stopPropagation();990});991row.appendChild(input);992993// Variant count toggle994const count = el('button', {995padding: '4px 6px', borderRadius: '5px',996border: '1px solid ' + BP.hairline, background: 'transparent',997fontFamily: MONO, fontSize: '11px', fontWeight: '600',998color: BP.textDim, cursor: 'pointer',999transition: 'color 0.12s ease, border-color 0.12s ease',1000flexShrink: '0', whiteSpace: 'nowrap',1001});1002count.textContent = '\u00D7' + selectedCount;1003count.title = 'Variants: click to change';1004count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });1005count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });1006count.addEventListener('click', (e) => {1007e.stopPropagation();1008selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;1009count.textContent = '\u00D7' + selectedCount;1010});1011row.appendChild(count);10121013// Go button1014const go = el('button', {1015padding: '5px 12px', borderRadius: '6px',1016border: 'none', background: BP.accent, color: BP.mark,1017fontFamily: FONT, fontSize: '12px', fontWeight: '600',1018cursor: 'pointer',1019transition: 'filter 0.12s ease, transform 0.1s ease',1020flexShrink: '0', whiteSpace: 'nowrap',1021});1022go.textContent = 'Go \u2192';1023go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');1024go.addEventListener('mouseleave', () => go.style.filter = 'none');1025go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');1026go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');1027go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });1028row.appendChild(go);10291030// Auto-focus input after a beat1031setTimeout(() => input.focus(), 60);1032return row;1033}10341035// --- Generating row ---10361037function buildGeneratingRow() {1038const row = el('div', {1039display: 'flex', alignItems: 'center', gap: '8px',1040padding: '2px 4px',1041});10421043// Action label1044const label = el('span', {1045fontWeight: '600', fontSize: '12px', color: BP.text,1046flexShrink: '0', whiteSpace: 'nowrap',1047});1048label.textContent = actionLabel();1049row.appendChild(label);10501051// Dots1052row.appendChild(buildDots(false));10531054// Status1055const status = el('span', {1056fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',1057marginLeft: 'auto',1058});1059// Variants currently arrive atomically in a single file edit, so a1060// per-variant counter would lie. Say what's true.1061status.textContent = arrivedVariants < expectedVariants1062? 'Generating ' + expectedVariants + ' variants...'1063: 'Done';1064row.appendChild(status);10651066return row;1067}10681069// --- Cycling row ---10701071const TUNE_ICON_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>';10721073function buildCyclingRow() {1074const row = el('div', {1075display: 'flex', alignItems: 'center', gap: '6px',1076padding: '1px 2px',1077});10781079// Prev1080const prev = navBtn('\u2190');1081prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); });1082if (visibleVariant <= 1) prev.style.opacity = '0.3';1083row.appendChild(prev);10841085// Dots (clickable)1086row.appendChild(buildDots(true));10871088// Counter1089const counter = el('span', {1090fontFamily: MONO, fontSize: '11px', fontWeight: '500',1091color: BP.textDim, minWidth: '24px', textAlign: 'center',1092});1093counter.textContent = visibleVariant + '/' + arrivedVariants;1094row.appendChild(counter);10951096// Next1097const next = navBtn('\u2192');1098next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); });1099if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';1100row.appendChild(next);11011102// Tune chip — only when the visible variant exposes params1103const visParams = parseVariantParams(getVisibleVariantEl());1104const hasParams = visParams.length > 0;1105if (hasParams) {1106const tune = el('button', {1107display: 'inline-flex', alignItems: 'center', gap: '6px',1108padding: '4px 10px', borderRadius: '5px',1109border: '1px solid transparent',1110background: tuneOpen ? BP.accentSoft : 'transparent',1111color: tuneOpen ? BP.accent : BP.text,1112fontFamily: FONT, fontSize: '11px', fontWeight: '500',1113cursor: 'pointer',1114transition: 'color 0.12s ease, background 0.12s ease',1115whiteSpace: 'nowrap',1116});1117tune.innerHTML = TUNE_ICON_SVG;1118const tuneLabel = document.createElement('span');1119tuneLabel.textContent = 'Tune';1120tune.appendChild(tuneLabel);1121const tuneBadge = document.createElement('span');1122Object.assign(tuneBadge.style, {1123display: 'inline-flex', alignItems: 'center', justifyContent: 'center',1124minWidth: '16px', height: '16px', padding: '0 4px',1125borderRadius: '999px',1126background: tuneOpen ? C.brand : BP.hairline,1127color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',1128fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',1129lineHeight: '1',1130boxSizing: 'border-box',1131});1132tuneBadge.textContent = String(visParams.length);1133tune.appendChild(tuneBadge);1134tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';1135tune.addEventListener('mouseenter', () => {1136if (!tuneOpen) tune.style.background = BP.accentSoft;1137});1138tune.addEventListener('mouseleave', () => {1139if (!tuneOpen) tune.style.background = 'transparent';1140});1141tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });1142tune.dataset.iceqTune = '1';1143row.appendChild(tune);1144}11451146// Spacer1147row.appendChild(el('div', { flex: '1' }));11481149// Accept — primary action, uses the site's saturated brand magenta1150// with paper-white text, not the theme-muted BP.accent.1151const accept = el('button', {1152padding: '5px 14px', borderRadius: '5px',1153border: 'none', background: C.brand, color: 'oklch(98% 0 0)',1154fontFamily: FONT, fontSize: '11px', fontWeight: '600',1155cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',1156whiteSpace: 'nowrap',1157});1158accept.textContent = '\u2713 Accept';1159accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');1160accept.addEventListener('mouseleave', () => accept.style.filter = 'none');1161accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');1162accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');1163accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });1164if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }1165row.appendChild(accept);11661167// Discard1168const discard = el('button', {1169padding: '4px 6px', borderRadius: '5px',1170border: '1px solid ' + BP.hairline, background: 'transparent',1171fontFamily: FONT, fontSize: '11px', color: BP.textDim,1172cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',1173});1174discard.textContent = '\u2715';1175discard.title = 'Discard all variants';1176discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });1177discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });1178discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });1179row.appendChild(discard);11801181return row;1182}11831184// --- Shared UI builders ---11851186// --- Saving row (waiting for agent to process accept/discard) ---11871188function buildSavingRow() {1189const row = el('div', {1190display: 'flex', alignItems: 'center', gap: '8px',1191padding: '2px 8px',1192});1193const spinner = el('div', {1194width: '14px', height: '14px', borderRadius: '50%',1195border: '2px solid ' + BP.hairline,1196borderTopColor: BP.accent,1197animation: 'impeccable-spin 0.6s linear infinite',1198flexShrink: '0',1199});1200row.appendChild(spinner);1201const label = el('span', {1202fontSize: '12px', color: BP.textDim, fontWeight: '500',1203});1204label.textContent = 'Applying variant...';1205row.appendChild(label);12061207// Inject the keyframes if not already present1208if (!document.getElementById(PREFIX + '-keyframes')) {1209const style = document.createElement('style');1210style.id = PREFIX + '-keyframes';1211style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }';1212document.head.appendChild(style);1213}1214return row;1215}12161217// --- Confirmed row (green success, auto-dismisses) ---12181219function buildConfirmedRow() {1220const row = el('div', {1221display: 'flex', alignItems: 'center', gap: '8px',1222padding: '2px 8px',1223});1224const check = el('span', {1225fontSize: '15px', lineHeight: '1', flexShrink: '0',1226color: 'oklch(45% 0.15 145)',1227});1228check.textContent = '\u2713';1229row.appendChild(check);1230const label = el('span', {1231fontSize: '12px', color: 'oklch(35% 0.1 145)', fontWeight: '600',1232});1233label.textContent = 'Variant applied';1234row.appendChild(label);1235return row;1236}12371238// --- Shared UI builders ---12391240function buildDots(clickable) {1241const container = el('div', {1242display: 'flex', alignItems: 'center', gap: '4px',1243});1244for (let i = 1; i <= expectedVariants; i++) {1245const arrived = i <= arrivedVariants;1246const active = i === visibleVariant;1247// active: solid site-brand magenta dot. arrived+inactive: muted neutral.1248// pending (not yet arrived): faint outline ring. No borders on arrived1249// dots — the previous "accent ring + ash fill" combo read as noisy1250// magenta chips, especially when all variants had arrived and every1251// dot wore an accent ring.1252const dotBg = active ? C.brand1253: arrived ? BP.textDim1254: 'transparent';1255const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;1256const dot = el('div', {1257width: active ? '8px' : '6px',1258height: active ? '8px' : '6px',1259borderRadius: '50%',1260background: dotBg,1261border: dotBorder,1262boxSizing: 'border-box',1263transition: 'all 0.2s ' + EASE,1264cursor: (clickable && arrived) ? 'pointer' : 'default',1265transform: arrived ? 'scale(1)' : 'scale(0.85)',1266opacity: arrived ? (active ? '1' : '0.6') : '0.4',1267});1268if (clickable && arrived) {1269const idx = i;1270dot.addEventListener('click', (e) => {1271e.stopPropagation();1272visibleVariant = idx;1273showVariantInDOM(currentSessionId, idx);1274updateSelectedElement();1275updateBarContent('cycling');1276});1277}1278container.appendChild(dot);1279}1280return container;1281}12821283function navBtn(text) {1284const b = el('button', {1285width: '26px', height: '26px', borderRadius: '5px',1286border: '1px solid ' + BP.hairline, background: 'transparent',1287color: BP.text, fontFamily: FONT, fontSize: '13px',1288cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',1289transition: 'border-color 0.12s ease, background 0.12s ease',1290padding: '0', lineHeight: '1',1291});1292b.textContent = text;1293b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });1294b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });1295return b;1296}12971298function actionLabel() {1299const a = ACTIONS.find(a => a.value === selectedAction);1300return a ? a.label : 'Freeform';1301}13021303function el(tag, styles) {1304const e = document.createElement(tag);1305if (styles) Object.assign(e.style, styles);1306return e;1307}13081309// ---------------------------------------------------------------------------1310// Action picker popover1311// ---------------------------------------------------------------------------13121313function initActionPicker() {1314const P = barPaletteForTheme(detectPageTheme());1315pickerEl = document.createElement('div');1316pickerEl.id = PREFIX + '-picker';1317Object.assign(pickerEl.style, {1318position: 'fixed', zIndex: Z.picker,1319display: 'none', opacity: '0',1320transform: 'scale(0.96) translateY(4px)',1321transformOrigin: 'bottom left',1322transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,1323background: P.surface,1324border: '1px solid ' + P.hairline,1325borderRadius: '10px',1326boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',1327padding: '6px',1328fontFamily: FONT,1329backdropFilter: 'blur(10px)',1330WebkitBackdropFilter: 'blur(10px)',1331});13321333// Build the chip grid1334const grid = el('div', {1335display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '3px',1336});13371338ACTIONS.forEach(action => {1339const chip = el('button', {1340display: 'flex', flexDirection: 'column', alignItems: 'center',1341gap: '4px',1342padding: '8px 6px', borderRadius: '6px',1343border: 'none',1344background: action.value === selectedAction ? P.accentSoft : 'transparent',1345color: action.value === selectedAction ? P.accent : P.text,1346fontFamily: FONT, fontSize: '11px', fontWeight: '500',1347cursor: 'pointer',1348transition: 'background 0.1s ease, color 0.1s ease',1349textAlign: 'center', whiteSpace: 'nowrap',1350});1351const iconWrap = el('span', {1352display: 'flex', alignItems: 'center', justifyContent: 'center',1353height: '20px', opacity: '0.9',1354});1355iconWrap.innerHTML = ICONS[action.value] || '';1356const labelEl = el('span', { lineHeight: '1' });1357labelEl.textContent = action.label;1358chip.appendChild(iconWrap);1359chip.appendChild(labelEl);1360chip.dataset.action = action.value;1361chip.addEventListener('mouseenter', () => {1362if (action.value !== selectedAction) chip.style.background = P.accentSoft;1363});1364chip.addEventListener('mouseleave', () => {1365chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';1366});1367chip.addEventListener('click', (e) => {1368e.stopPropagation();1369selectedAction = action.value;1370hideActionPicker();1371updateBarContent('configure');1372});1373grid.appendChild(chip);1374});13751376pickerEl.appendChild(grid);1377document.body.appendChild(pickerEl);1378defangOutsideHandlers(pickerEl);13791380// Cache the palette on the picker so toggleActionPicker's state refresh1381// uses the same theme-aware colors when it repaints chips.1382pickerEl.__iceq_palette = P;1383}13841385function toggleActionPicker() {1386if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }1387// Rebuild chips to reflect current selection1388const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());1389pickerEl.querySelectorAll('button').forEach(chip => {1390const isActive = chip.dataset.action === selectedAction;1391chip.style.background = isActive ? P.accentSoft : 'transparent';1392chip.style.color = isActive ? P.accent : P.text;1393});1394// Position above the bar1395const barRect = barEl.getBoundingClientRect();1396const pickerH = 170; // approximate; grows with icon + label rows1397let top = barRect.top - pickerH - 6;1398if (top < 8) top = barRect.bottom + 6;1399Object.assign(pickerEl.style, {1400top: top + 'px', left: barRect.left + 'px',1401display: 'block',1402});1403requestAnimationFrame(() => {1404pickerEl.style.opacity = '1';1405pickerEl.style.transform = 'scale(1) translateY(0)';1406});1407}14081409function hideActionPicker() {1410if (!pickerEl) return;1411pickerEl.style.opacity = '0';1412pickerEl.style.transform = 'scale(0.96) translateY(4px)';1413setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);1414}14151416// ---------------------------------------------------------------------------1417// Params panel (per-variant coarse controls)1418//1419// Variants may declare a parameter manifest via a JSON attribute on the1420// variant wrapper:1421//1422// <div data-impeccable-variant="1"1423// data-impeccable-params='[{"id":"density","kind":"steps",...}]'>1424//1425// The panel docks to the right edge of the outline during CYCLING and1426// exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped1427// CSS can respond instantly without regeneration:1428//1429// range / numeric toggle → CSS var (`--p-<id>`) used via var(--p-foo, N)1430// steps / boolean toggle → data-p-<id> attribute used via :scope[data-p-foo="..."]1431//1432// On variant switch, values reset to that variant's declared defaults.1433// On accept, current values are sent in the event payload so the agent1434// can bake them into the source-file write.1435// ---------------------------------------------------------------------------14361437let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)1438let paramsPanelInner = null; // translating content (carries bg, padding, knobs)1439let paramsPanelBody = null; // grid holding the knob cells1440let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values1441let tuneOpen = false; // whether the Tune popover is open right now14421443// Theme-aware Tune popover. Appears as a drawer that slides out from the1444// contextual bar's bar-facing edge (below if the bar sits below the1445// element, above otherwise). Same width as the bar. Auto-wraps to extra1446// rows when the knobs exceed one row. The bar's border-radius on the1447// popover side goes flat while open so the two shapes read as one.1448let paramsPanelPalette = null;14491450function initParamsPanel() {1451paramsPanelPalette = barPaletteForTheme(detectPageTheme());1452const P = paramsPanelPalette;14531454// Single element, always in the DOM. The slide animation is a CSS mask1455// with mask-size growing from 0% to 100% along the bar-facing axis — no1456// display toggle, no opacity toggle, no transform trickery. The mask1457// hides everything initially; as it grows, content is revealed from1458// the bar edge outward.1459paramsPanelEl = document.createElement('div');1460paramsPanelEl.id = PREFIX + '-params-panel';1461Object.assign(paramsPanelEl.style, {1462position: 'fixed', zIndex: String(Z.bar - 1),1463background: P.surfaceDeep,1464color: P.text,1465fontFamily: FONT,1466padding: '14px 18px',1467boxSizing: 'border-box',1468borderRadius: '0 0 10px 10px',1469pointerEvents: 'none',1470backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',14711472// clip-path is the same conceptual reveal as mask but with rock-solid1473// transition support across engines. Closed state clips from the far1474// edge; open = inset(0) shows everything.1475clipPath: 'inset(0 0 100% 0)',1476transition: 'clip-path 0.44s ' + EASE,14771478// Park off-screen until positionParamsPanel places it. These are NOT1479// in the transition list, so they snap instantly — no fly-in from the1480// top-left when first shown.1481top: '-9999px', left: '-9999px', width: '0',1482});14831484paramsPanelBody = el('div', {1485display: 'grid',1486gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',1487gap: '12px 16px',1488});14891490paramsPanelEl.appendChild(paramsPanelBody);1491document.body.appendChild(paramsPanelEl);1492// Don't override pointer-events: the panel toggles between 'none' (closed,1493// click-through) and 'auto' (open) on its own. Just silence the host's1494// outside-interaction listeners while the panel is open.1495defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });1496paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code1497}14981499function getVisibleVariantEl() {1500if (!currentSessionId) return null;1501const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');1502if (!wrapper) return null;1503return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');1504}15051506function parseVariantParams(variantEl) {1507if (!variantEl) return [];1508const raw = variantEl.getAttribute('data-impeccable-params');1509if (!raw) return [];1510try {1511const parsed = JSON.parse(raw);1512return Array.isArray(parsed) ? parsed : [];1513} catch (err) {1514console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);1515return [];1516}1517}15181519function applyParamValue(variantEl, param, value) {1520if (!variantEl) return;1521const attr = 'data-p-' + param.id;1522if (param.kind === 'range') {1523variantEl.style.setProperty('--p-' + param.id, String(value));1524} else if (param.kind === 'toggle') {1525const on = !!value;1526variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');1527if (on) variantEl.setAttribute(attr, 'on');1528else variantEl.removeAttribute(attr);1529} else if (param.kind === 'steps') {1530variantEl.setAttribute(attr, String(value));1531}1532}15331534function applyParamDefaults(variantEl, params) {1535paramsCurrentValues = {};1536for (const p of params) {1537paramsCurrentValues[p.id] = p.default;1538applyParamValue(variantEl, p, p.default);1539}1540}15411542function formatRangeValue(input) {1543const max = parseFloat(input.max), min = parseFloat(input.min);1544const v = parseFloat(input.value);1545if (!isFinite(v)) return input.value;1546return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));1547}15481549function buildParamsPanel(variantEl, params) {1550const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());1551paramsPanelBody.innerHTML = '';1552for (const p of params) {1553const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });1554const labelRow = el('div', {1555display: 'flex', justifyContent: 'space-between',1556alignItems: 'baseline', gap: '8px',1557});1558const lbl = el('span', {1559fontSize: '10.5px', fontWeight: '600', color: P.text,1560letterSpacing: '0.03em',1561});1562lbl.textContent = p.label || p.id;1563labelRow.appendChild(lbl);1564const readout = el('span', {1565fontSize: '10.5px', color: P.textDim,1566fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',1567});1568labelRow.appendChild(readout);1569row.appendChild(labelRow);15701571if (p.kind === 'range') {1572const input = document.createElement('input');1573input.type = 'range';1574input.min = String(p.min != null ? p.min : 0);1575input.max = String(p.max != null ? p.max : 1);1576input.step = String(p.step != null ? p.step : 0.05);1577input.value = String(p.default);1578Object.assign(input.style, {1579width: '100%', accentColor: C.brand, cursor: 'pointer',1580});1581readout.textContent = formatRangeValue(input);1582input.addEventListener('input', (e) => {1583e.stopPropagation();1584const v = parseFloat(input.value);1585paramsCurrentValues[p.id] = v;1586readout.textContent = formatRangeValue(input);1587applyParamValue(variantEl, p, v);1588queueCheckpoint('param_changed');1589});1590row.appendChild(input);1591} else if (p.kind === 'toggle') {1592const initial = !!p.default;1593readout.textContent = initial ? 'On' : 'Off';1594const track = el('button', {1595position: 'relative', width: '36px', height: '20px',1596borderRadius: '10px', border: 'none', padding: '0',1597cursor: 'pointer',1598background: initial ? C.brand : P.hairline,1599transition: 'background 0.15s ease',1600alignSelf: 'flex-start',1601});1602const knob = el('span', {1603position: 'absolute', top: '2px',1604left: initial ? '18px' : '2px',1605width: '16px', height: '16px', borderRadius: '50%',1606background: 'oklch(98% 0 0)',1607transition: 'left 0.18s ' + EASE,1608boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',1609});1610track.appendChild(knob);1611track.addEventListener('click', (e) => {1612e.stopPropagation();1613const next = !paramsCurrentValues[p.id];1614paramsCurrentValues[p.id] = next;1615track.style.background = next ? C.brand : P.hairline;1616knob.style.left = next ? '18px' : '2px';1617readout.textContent = next ? 'On' : 'Off';1618applyParamValue(variantEl, p, next);1619queueCheckpoint('param_changed');1620});1621row.appendChild(track);1622} else if (p.kind === 'steps') {1623const opts = (p.options || []).map(o =>1624typeof o === 'string' ? { value: o, label: o } : o1625);1626const activeOpt = opts.find(o => o.value === p.default) || opts[0];1627readout.textContent = activeOpt ? activeOpt.label : String(p.default);1628const segRow = el('div', {1629display: 'grid',1630gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',1631gap: '1px', padding: '2px',1632background: P.hairline, borderRadius: '5px',1633});1634const segBtns = [];1635opts.forEach(o => {1636const active = o.value === p.default;1637const b = el('button', {1638padding: '5px 4px', border: 'none', borderRadius: '3px',1639background: active ? C.brand : 'transparent',1640color: active ? 'oklch(98% 0 0)' : P.text,1641fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',1642cursor: 'pointer', whiteSpace: 'nowrap',1643transition: 'background 0.1s ease, color 0.1s ease',1644});1645b.textContent = o.label;1646b.addEventListener('click', (e) => {1647e.stopPropagation();1648paramsCurrentValues[p.id] = o.value;1649readout.textContent = o.label;1650segBtns.forEach(({ btn, val }) => {1651const on = val === o.value;1652btn.style.background = on ? C.brand : 'transparent';1653btn.style.color = on ? 'oklch(98% 0 0)' : P.text;1654});1655applyParamValue(variantEl, p, o.value);1656queueCheckpoint('param_changed');1657});1658segRow.appendChild(b);1659segBtns.push({ btn: b, val: o.value });1660});1661row.appendChild(segRow);1662}16631664paramsPanelBody.appendChild(row);1665}1666}16671668// Decide which way the popover opens: away from the picked element. If the1669// bar landed below the element, popover slides DOWN from the bar's bottom.1670// If the bar landed above, popover slides UP from the bar's top.1671function popoverDirection() {1672if (!barEl || !selectedElement) return 'below';1673const br = barEl.getBoundingClientRect();1674const er = selectedElement.getBoundingClientRect();1675return br.top >= er.bottom - 4 ? 'below' : 'above';1676}16771678// The popover overlaps the bar by OVERLAP px on the bar-facing side. With1679// popover z-index below bar, that overlap sits behind bar (invisible) and1680// reinforces the "tucked behind" feel. Padding compensates so the real1681// content starts flush with bar's outer edge.1682const TUNE_OVERLAP = 6;16831684// Closed clip-path depends on direction: for 'below' clip from the far1685// (bottom) edge so the reveal grows downward from the bar; for 'above'1686// clip from the top edge so the reveal grows upward from the bar.1687function closedClipPath(direction) {1688return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';1689}16901691function setClipPath(value, withTransition) {1692const saved = paramsPanelEl.style.transition;1693if (!withTransition) paramsPanelEl.style.transition = 'none';1694paramsPanelEl.style.clipPath = value;1695if (!withTransition) {1696void paramsPanelEl.offsetHeight;1697paramsPanelEl.style.transition = saved;1698}1699}17001701function positionParamsPanel() {1702if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;1703const br = barEl.getBoundingClientRect();1704const direction = popoverDirection();1705const prevDirection = paramsPanelEl.dataset.tuneDirection;17061707// top/left/width are NOT in the transition list, so they snap instantly.1708paramsPanelEl.style.left = br.left + 'px';1709paramsPanelEl.style.width = br.width + 'px';17101711if (direction === 'below') {1712paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';1713paramsPanelEl.style.borderRadius = '0 0 10px 10px';1714paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';1715paramsPanelEl.style.paddingBottom = '14px';1716} else {1717const ih = paramsPanelEl.offsetHeight || 80;1718paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';1719paramsPanelEl.style.borderRadius = '10px 10px 0 0';1720paramsPanelEl.style.paddingTop = '14px';1721paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';1722}1723paramsPanelEl.dataset.tuneDirection = direction;17241725// If currently closed and direction flipped (or first-time setup),1726// snap the clip-path to the new direction's closed pose without1727// transitioning (so the clip doesn't slide across the element).1728if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {1729setClipPath(closedClipPath(direction), false);1730}1731}17321733function showParamsPanel() {1734if (!paramsPanelEl) return;1735positionParamsPanel();1736paramsPanelEl.style.pointerEvents = 'auto';1737// rAF so the positioning paint commits before the transition fires.1738requestAnimationFrame(() => {1739setClipPath('inset(0 0 0 0)', true);1740});1741}17421743function hideParamsPanel() {1744if (!paramsPanelEl) return;1745paramsPanelEl.style.pointerEvents = 'none';1746const direction = paramsPanelEl.dataset.tuneDirection || 'below';1747setClipPath(closedClipPath(direction), true);1748}17491750// Build/rebuild the panel's contents for the current variant AND apply1751// its defaults to the variant wrapper (so scoped CSS responds even before1752// the user opens the popover). Visibility is governed by tuneOpen.1753function refreshParamsPanel() {1754if (state !== 'CYCLING') {1755paramsCurrentValues = {};1756tuneOpen = false;1757hideParamsPanel();1758return;1759}1760const variantEl = getVisibleVariantEl();1761const params = parseVariantParams(variantEl);1762if (!variantEl || params.length === 0) {1763paramsCurrentValues = {};1764tuneOpen = false;1765hideParamsPanel();1766return;1767}1768applyParamDefaults(variantEl, params);1769buildParamsPanel(variantEl, params);1770if (tuneOpen) {1771// If already visible (variant cycled while open), refresh in place1772// instead of re-running the clip-path animation.1773const alreadyVisible = paramsPanelEl.style.display === 'block'1774&& paramsPanelEl.style.opacity === '1';1775if (alreadyVisible) positionParamsPanel();1776else showParamsPanel();1777} else {1778hideParamsPanel();1779}1780}17811782function toggleTunePopover() {1783if (tuneOpen) { closeTunePopover(); return; }1784openTunePopover();1785}17861787function openTunePopover() {1788if (state !== 'CYCLING') return;1789const variantEl = getVisibleVariantEl();1790const params = parseVariantParams(variantEl);1791if (!variantEl || params.length === 0) return;1792// Build fresh to ensure the current variant's controls are shown.1793applyParamDefaults(variantEl, params);1794buildParamsPanel(variantEl, params);1795tuneOpen = true;1796showParamsPanel();1797// Kill the bar's shadow on the popover-facing side so the dark popover1798// doesn't pick up a bright glow line.1799if (barEl) {1800const direction = paramsPanelEl?.dataset.tuneDirection || 'below';1801barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;1802}1803// Re-render the bar so the Tune chip picks up the active styling.1804updateBarContent('cycling');1805}18061807function closeTunePopover() {1808tuneOpen = false;1809hideParamsPanel();1810if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;1811if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {1812updateBarContent('cycling');1813}1814}18151816// ---------------------------------------------------------------------------1817// Variant cycling in DOM1818// ---------------------------------------------------------------------------18191820function showVariantInDOM(sessionId, num) {1821const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');1822if (!wrapper) return;1823for (const child of wrapper.children) {1824const v = child.dataset ? child.dataset.impeccableVariant : null;1825if (!v) continue;1826child.style.display = (v === String(num)) ? '' : 'none';1827}1828// Unconditional refresh — covers first-reveal (no-op if state isn't1829// CYCLING yet, the subsequent CYCLING transition triggers its own1830// refresh) and every cycle step.1831refreshParamsPanel();1832}18331834/**1835* No-HMR fallback: fetch the raw source file from the live server,1836* parse it, extract the variant wrapper, and inject it into the live DOM.1837* This works even when the dev server caches HTML (Bun, static servers).1838*/1839function injectVariantsFromSource(filePath, sessionId) {1840const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);1841fetch(url)1842.then(r => { if (!r.ok) throw new Error(r.status); return r.text(); })1843.then(html => {1844// Parse the raw source HTML1845const parser = new DOMParser();1846const doc = parser.parseFromString(html, 'text/html');1847const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]');1848if (!srcWrapper) {1849console.error('[impeccable] Variant wrapper not found in source file.');1850return;1851}18521853// Find the original element in the live DOM.1854// The original is inside the wrapper in the source. We find the1855// corresponding element in the live DOM by matching the first child's1856// tag + classes from the original snapshot.1857const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child');1858if (!origContent) return;18591860const tag = origContent.tagName.toLowerCase();1861const cls = origContent.className;1862let liveEl = null;1863if (origContent.id) {1864liveEl = document.getElementById(origContent.id);1865} else if (cls) {1866// Find by tag + exact class match1867const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]);1868for (const c of candidates) {1869if (c.className === cls && !own(c)) { liveEl = c; break; }1870}1871}18721873if (!liveEl) {1874console.error('[impeccable] Could not find original element in live DOM.');1875return;1876}18771878const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0;18791880// Replace the live element with the full wrapper from source1881const wrapper = srcWrapper.cloneNode(true);1882liveEl.parentElement.replaceChild(wrapper, liveEl);18831884// Update state: count variants, preserving the user's current variant1885// when a late HMR/source reinjection lands after they have cycled.1886const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');1887arrivedVariants = variants.length;1888expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);1889const saved = loadSession();1890const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;1891visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants1892? previousVisibleVariant1893: (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);1894showVariantInDOM(sessionId, visibleVariant);18951896// Update selectedElement to the visible variant's content1897selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement;18981899state = 'CYCLING';1900hideShaderOverlay();1901updateBarContent('cycling');1902refreshParamsPanel();1903saveSession();1904console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');1905})1906.catch(err => {1907console.error('[impeccable] Failed to fetch source:', err);1908showToast('Could not load variants. Try refreshing the page.', 5000);1909});1910}19111912function cycleVariant(dir) {1913const next = visibleVariant + dir;1914if (next < 1 || next > arrivedVariants) return;1915visibleVariant = next;1916showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself1917updateSelectedElement();1918updateBarContent('cycling');1919saveSession();1920queueCheckpoint('variant_changed');1921}19221923function updateSelectedElement() {1924if (!currentSessionId) return;1925const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');1926if (!wrapper) return;1927const visEl = pickVariantContent(wrapper, visibleVariant);1928if (visEl) selectedElement = visEl;1929}19301931function readVisibleVariantFromDOM(sessionId) {1932const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');1933if (!wrapper) return 0;1934const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');1935for (const variant of variants) {1936if (variant.style.display === 'none') continue;1937const idx = parseInt(variant.dataset.impeccableVariant || '0', 10);1938if (idx > 0) return idx;1939}1940return 0;1941}19421943// Resolve the element that represents the variant's visible content.1944// Contract: each variant div should contain exactly one top-level element1945// (the full replacement). In practice a model may ship loose siblings or1946// lead with <style>/<script>. Be defensive: skip non-visual elements, and1947// if the variant has multiple element children, use the variant div itself1948// (it wraps all of them and gets correct bounds).1949function pickVariantContent(wrapper, index) {1950if (!wrapper) return null;1951const variantDiv = wrapper.querySelector('[data-impeccable-variant="' + index + '"]');1952if (!variantDiv) return null;1953const NON_VISUAL = new Set(['STYLE', 'SCRIPT', 'LINK', 'META', 'TEMPLATE']);1954const visual = [];1955for (const child of variantDiv.children) {1956if (!NON_VISUAL.has(child.tagName)) visual.push(child);1957}1958if (visual.length === 1) return visual[0];1959return variantDiv;1960}19611962// Hold window.scrollY at a fixed value across DOM mutations inside the1963// session's wrapper (HMR patches, variant inserts, cycle swaps).1964function startScrollLock(sessionId, initialTargetY) {1965stopScrollLock();1966scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)1967? initialTargetY1968: window.scrollY;1969console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY });19701971try { history.scrollRestoration = 'manual'; } catch {}19721973const prevHtmlAnchor = document.documentElement.style.overflowAnchor;1974const prevBodyAnchor = document.body.style.overflowAnchor;1975document.documentElement.style.overflowAnchor = 'none';1976document.body.style.overflowAnchor = 'none';19771978const correct = (why) => {1979scrollLockRaf = null;1980if (scrollLockTargetY == null) return;1981const before = window.scrollY;1982const delta = before - scrollLockTargetY;1983if (Math.abs(delta) < 0.5) {1984console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY });1985return;1986}1987window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });1988console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY });1989};1990const schedule = (why) => {1991if (scrollLockRaf != null) return;1992scrollLockRaf = requestAnimationFrame(() => correct(why));1993};19941995scrollLockObserver = new MutationObserver((mutations) => {1996for (const m of mutations) {1997if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) {1998const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(',');1999console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY });2000schedule('mutation-in-wrapper');2001return;2002}2003for (const n of m.addedNodes) {2004if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) {2005console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY });2006schedule('wrapper-added');2007return;2008}2009}2010}2011});2012scrollLockObserver.observe(document.body, { childList: true, subtree: true });20132014scrollLockAbort = new AbortController();2015scrollLockAbort.signal.addEventListener('abort', () => {2016document.documentElement.style.overflowAnchor = prevHtmlAnchor;2017document.body.style.overflowAnchor = prevBodyAnchor;2018}, { once: true });2019const sig = { signal: scrollLockAbort.signal };2020// Track whether the most recent scroll came from a user gesture. We2021// gate user-scroll re-anchoring on this flag so programmatic smooth2022// scrolls (browser reload-restore, scrollIntoView from other scripts)2023// don't accidentally update our target.2024let userGestureAt = 0;2025const USER_GESTURE_WINDOW_MS = 250;20262027const reanchor = (why) => {2028if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }2029const prevTarget = scrollLockTargetY;2030scrollLockTargetY = window.scrollY;2031writeScrollY(scrollLockTargetY);2032console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY });2033};2034const markGesture = (why) => {2035userGestureAt = performance.now();2036reanchor(why);2037};2038window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });2039window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });2040window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });2041window.addEventListener('keydown', (e) => {2042if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);2043}, sig);20442045// Correct on EVERY scroll event: whether it's the browser's2046// post-reload animated restore or some other script calling2047// scrollIntoView, we want to snap back immediately. Only skip if a2048// user gesture fired in the last 250ms.2049let lastLoggedScrollY = window.scrollY;2050window.addEventListener('scroll', () => {2051const now = window.scrollY;2052if (Math.abs(now - lastLoggedScrollY) > 5) {2053console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY });2054lastLoggedScrollY = now;2055}2056if (scrollLockTargetY == null) return;2057if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;2058if (Math.abs(now - scrollLockTargetY) < 0.5) return;2059console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY });2060window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });2061}, { passive: true, ...sig });20622063// Apply target synchronously, not via rAF — racing the browser's2064// restore or a smooth-scroll animation means we want to win now.2065if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {2066window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });2067console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY });2068}2069}20702071function stopScrollLock() {2072if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }2073if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }2074if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }2075scrollLockTargetY = null;2076// NOTE: do NOT clear the persistent scroll key here. startScrollLock2077// calls us as a reset, and clearing the key would nuke the Go-time2078// scrollY that the next resume needs to read.2079}20802081// ---------------------------------------------------------------------------2082// MutationObserver for progressive variant reveal2083// ---------------------------------------------------------------------------20842085function startVariantObserver(sessionId) {2086let updating = false; // re-entrancy guard20872088const obs = new MutationObserver((mutations) => {2089if (updating) return;20902091// Only react to mutations that add nodes with data-impeccable-variant,2092// or mutations inside the variant wrapper. Ignore our own bar/UI changes.2093let dominated = false;2094for (const m of mutations) {2095if (m.target.closest?.('[data-impeccable-variants]')) { dominated = true; break; }2096for (const n of m.addedNodes) {2097if (n.nodeType !== 1) continue;2098// Direct hit: the added node itself is the wrapper or a variant.2099if (n.dataset?.impeccableVariants || n.dataset?.impeccableVariant) {2100dominated = true; break;2101}2102// Subtree hit: framework HMR (notably SvelteKit) sometimes replaces2103// a whole subtree where the wrapper is a descendant of the added2104// node. Without this check, the observer ignores those mutations2105// and the session stays in GENERATING forever.2106if (n.querySelector?.('[data-impeccable-variants],[data-impeccable-variant]')) {2107dominated = true; break;2108}2109}2110if (dominated) break;2111}2112if (!dominated) return;21132114const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');2115if (!wrapper) return;21162117// Re-anchor selectedElement if it was detached by live-wrap's HMR swap.2118// Without this, the shader / highlight / bar track a zero-rect phantom2119// and the overlay appears frozen.2120if (selectedElement && !document.body.contains(selectedElement)) {2121selectedElement = pickVariantContent(wrapper, 'original') || wrapper;2122}21232124const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');2125const count = variants.length;21262127// Nothing new2128if (count <= arrivedVariants) return;21292130updating = true;2131arrivedVariants = count;2132if (visibleVariant === 0 && arrivedVariants > 0) {2133const saved = loadSession();2134const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;2135visibleVariant = savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1;2136showVariantInDOM(sessionId, visibleVariant);2137// showVariantInDOM hid the original (display:none); if we were still2138// anchored to the original's content, its boundingRect is now zero2139// and the bar snaps to (0,0). Re-point at the visible variant instead.2140const visEl = pickVariantContent(wrapper, visibleVariant);2141if (visEl) selectedElement = visEl;2142}21432144const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');2145if (expected > 0) expectedVariants = expected;21462147if (arrivedVariants >= expectedVariants && expectedVariants > 0) {2148state = 'CYCLING';2149hideShaderOverlay();2150updateBarContent('cycling');2151refreshParamsPanel();2152} else if (state === 'GENERATING') {2153updateBarContent('generating');2154}2155saveSession();2156queueCheckpoint(state === 'CYCLING' ? 'variants_ready' : 'variants_progress');2157updating = false;2158});21592160obs.observe(document.body, { childList: true, subtree: true });2161return obs;2162}21632164// ---------------------------------------------------------------------------2165// Bar scroll tracking2166// ---------------------------------------------------------------------------21672168function startScrollTracking() {2169function tick() {2170if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {2171positionBar();2172showHighlight(selectedElement);2173if (tuneOpen) positionParamsPanel();2174}2175if (annotActive) positionAnnotOverlay(selectedElement);2176// Shader overlay (via debug P toggle or generation) is repositioned2177// by its own branch below; debug no longer has a separate overlay.2178if (shaderState) positionShaderOverlay();2179scrollRaf = requestAnimationFrame(tick);2180}2181scrollRaf = requestAnimationFrame(tick);2182}21832184function stopScrollTracking() {2185if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; }2186}21872188// ---------------------------------------------------------------------------2189// SSE (server→browser) + fetch POST (browser→server)2190// Zero-dependency replacement for WebSocket.2191// ---------------------------------------------------------------------------21922193let evtSource = null;2194let sseRetries = 0;2195const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble21962197function connectSSE() {2198evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);21992200evtSource.onopen = () => {2201sseRetries = 0; // reset on successful (re)connect2202};22032204evtSource.onmessage = (e) => {2205sseRetries = 0; // reset on any successful message2206let msg; try { msg = JSON.parse(e.data); } catch { return; }2207switch (msg.type) {2208case 'connected':2209hasProjectContext = !!msg.hasProjectContext;2210if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000);2211console.log('[impeccable] Live mode connected.');2212if (state === 'IDLE') state = 'PICKING';2213break;2214case 'done':2215// Variants already arrived via HMR → normal transition.2216if (arrivedVariants >= expectedVariants && expectedVariants > 0) {2217if (state === 'GENERATING') {2218state = 'CYCLING';2219updateBarContent('cycling');2220refreshParamsPanel();2221}2222break;2223}2224// Variants are in source but not in the DOM yet. Common when the2225// picked element lived inside conditional render (closed modal,2226// hidden tab, a route the user navigated away from). The variant2227// MutationObserver stays armed and auto-transitions to CYCLING2228// the moment the wrapper actually mounts. Nudge the user toward2229// that path with a toast — better than the prior force-reload2230// which reset framework state and left the session stuck.2231setTimeout(() => {2232if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;2233if (state !== 'GENERATING') return;2234showToast(2235"Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",223615000,2237);2238}, 2000);2239break;2240case 'error':2241console.error('[impeccable] Error:', msg.message);2242showToast('Error: ' + msg.message, 5000);2243hideBar();2244state = 'PICKING';2245break;2246}2247};22482249evtSource.onerror = () => {2250sseRetries++;2251if (sseRetries <= SSE_MAX_RETRIES) {2252console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');2253return; // EventSource auto-reconnects2254}2255// Server is gone. Clean up gracefully.2256console.log('[impeccable] Live server unreachable. Cleaning up UI.');2257evtSource.close();2258evtSource = null;2259handleServerLost();2260};2261}22622263/** Server died or became unreachable. Reset UI to a clean state. */2264function handleServerLost() {2265const recoveryState = currentSessionId ? state : 'IDLE';2266if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {2267showToast('Live server disconnected. Session ended.', 5000);2268}2269hideBar();2270hideHighlight();2271hideShaderOverlay();2272hideAnnotOverlay();2273stopScrollTracking();2274if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }2275stopScrollLock();2276// Preserve local session state on server loss. The durable journal is the2277// source of truth, but localStorage plus the variant wrapper lets the UI2278// resume after a helper restart or page reload instead of treating a2279// transient disconnect as an explicit discard.2280selectedElement = null;2281selectedAction = 'impeccable';2282state = recoveryState;2283if (currentSessionId) saveSession();2284}22852286function sendEvent(msg, opts) {2287msg.token = TOKEN;2288function handleFailure(err) {2289console.error('[impeccable] Failed to send event:', err);2290if (opts && opts.throwOnError) throw err;2291return null;2292}2293return fetch('http://localhost:' + PORT + '/events', {2294method: 'POST',2295headers: { 'Content-Type': 'application/json' },2296body: JSON.stringify(msg),2297}).then(res => {2298if (res.ok) return res;2299return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText));2300}).catch(handleFailure);2301}23022303function checkpointPayload(reason) {2304return {2305type: 'checkpoint',2306id: currentSessionId,2307revision: sessionState.nextCheckpointRevision(),2308owner: browserOwner,2309phase: String(state || '').toLowerCase(),2310reason,2311pageUrl: location.pathname,2312expectedVariants,2313arrivedVariants,2314visibleVariant,2315paramValues: { ...paramsCurrentValues },2316};2317}23182319function sendCheckpoint(reason) {2320if (!currentSessionId) return Promise.resolve(null);2321return sendEvent(checkpointPayload(reason)).catch(() => null);2322}23232324function queueCheckpoint(reason) {2325if (!currentSessionId) return;2326if (checkpointTimer) clearTimeout(checkpointTimer);2327checkpointTimer = setTimeout(() => {2328checkpointTimer = null;2329sendCheckpoint(reason);2330}, 120);2331}23322333// ---------------------------------------------------------------------------2334// Event handlers2335// ---------------------------------------------------------------------------23362337function handleMouseMove(e) {2338if (state !== 'PICKING' || !pickActive) return;2339const target = document.elementFromPoint(e.clientX, e.clientY);2340if (!target || !pickable(target) || target === hoveredElement) return;2341hoveredElement = target;2342showHighlight(target);2343}23442345function handleClick(e) {2346// Close action picker on any outside click2347if (pickerEl?.style.display !== 'none' && !own(e.target)) {2348hideActionPicker();2349}2350// Close Tune popover on outside click (anything outside panel + bar)2351if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {2352closeTunePopover();2353}2354// In CONFIGURING: click outside the bar and selected element returns to PICKING2355if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {2356hideBar();2357stopScrollTracking();2358hideAnnotOverlay();2359clearAnnotations();2360state = 'PICKING';2361hoveredElement = null;2362hideHighlight();2363return;2364}2365if (state !== 'PICKING' || !pickActive) return;2366if (own(e.target)) return;2367if (!hoveredElement || !pickable(hoveredElement)) return;2368e.preventDefault();2369e.stopPropagation();2370selectedElement = hoveredElement;2371state = 'CONFIGURING';2372showHighlight(selectedElement);2373clearAnnotations();2374showAnnotOverlay(selectedElement);2375showBar('configure');2376startScrollTracking();2377maybePrefetchPage();2378maybeWarnConditionalAncestor(selectedElement);2379}23802381/**2382* Surface a brief, non-blocking heads-up when the picked element lives2383* inside a container whose visibility is gated by ephemeral state — modals,2384* collapsible panels, popovers, off-screen tab panels. If HMR remounts the2385* parent during generation (Vite Fast Refresh, SvelteKit page reload), the2386* variants land in source but stay invisible until the user re-opens the2387* container. Telling the user upfront is much friendlier than the silent2388* timeout-then-toast that they'd otherwise hit.2389*2390* Heuristic, intentionally narrow — only fires for unambiguous cases so2391* we don't cry wolf on every nested element.2392*/2393function maybeWarnConditionalAncestor(el) {2394let node = el?.parentElement;2395let depth = 0;2396while (node && depth < 12) {2397// 1. Active dialog / modal2398if (node.getAttribute && node.getAttribute('role') === 'dialog'2399&& node.getAttribute('aria-modal') === 'true') {2400showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);2401return;2402}2403// 2. Common Radix / shadcn / headless-ui open-state attribute2404if (node.dataset && node.dataset.state === 'open') {2405showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);2406return;2407}2408// 3. Tab panel — only meaningful when the page also shows ANOTHER2409// tab as selected. A single tabpanel with no tablist is just a static2410// section in disguise and isn't conditional.2411if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {2412const list = document.querySelector('[role="tablist"]');2413if (list) {2414const tabs = list.querySelectorAll('[role="tab"]');2415if (tabs.length > 1) {2416showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);2417return;2418}2419}2420}2421// 4. Collapsible: aria-expanded sibling. Look for the trigger button.2422if (node.id) {2423const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);2424if (trigger) {2425showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);2426return;2427}2428}2429node = node.parentElement;2430depth++;2431}2432}24332434// Fire a lightweight prefetch event the first time the user selects an2435// element on a given route. The agent uses this to Read the underlying file2436// into context before Go is hit, shaving the read off the critical path.2437// Dedupe per session by pathname — clicking around on the same page doesn't2438// re-fire.2439//2440// DISABLED: quick-Go workflows pay an extra harness round trip because2441// prefetch + generate arrive as two events instead of one. Re-enable with2442// a browser-side debounce (~800–1000ms, cancelled on Go) if we want to2443// resurrect this. Server validator and skill dispatch remain in place so2444// flipping this flag is the only change needed.2445const PREFETCH_ENABLED = false;2446const prefetchedPaths = new Set();2447function maybePrefetchPage() {2448if (!PREFETCH_ENABLED) return;2449const path = location.pathname;2450if (prefetchedPaths.has(path)) return;2451prefetchedPaths.add(path);2452sendEvent({ type: 'prefetch', pageUrl: path });2453}24542455function handleKeyDown(e) {2456// When the annotation input is focused, let it handle its own keys.2457if (annotEditing && annotEditing.input && e.target === annotEditing.input) return;2458if (e.key === 'Escape') {2459e.preventDefault();2460if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; }2461if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; }2462if (state === 'CYCLING') { handleDiscard(); return; }2463if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt2464if (state === 'PICKING') {2465// Use togglePick so the "Pick" button in the global bar also flips2466// off, otherwise the bar stays lit while nothing else is active.2467if (pickActive) togglePick();2468else { hideHighlight(); state = 'IDLE'; }2469return;2470}2471}24722473// Arrow/Enter nav works in PICKING (hover) and CONFIGURING (selected, input empty)2474var navEl = (state === 'PICKING') ? hoveredElement : (state === 'CONFIGURING') ? selectedElement : null;2475if (navEl && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || (e.key === 'Enter' && state === 'PICKING'))) {2476let next = null;2477if (e.key === 'ArrowDown' && !e.shiftKey) {2478next = navEl.nextElementSibling;2479while (next && !pickable(next)) next = next.nextElementSibling;2480} else if (e.key === 'ArrowUp' && !e.shiftKey) {2481next = navEl.previousElementSibling;2482while (next && !pickable(next)) next = next.previousElementSibling;2483} else if (e.key === 'ArrowUp' && e.shiftKey) {2484next = navEl.parentElement;2485if (next && !pickable(next)) next = null;2486} else if (e.key === 'ArrowDown' && e.shiftKey) {2487next = navEl.firstElementChild;2488while (next && !pickable(next)) next = next.nextElementSibling;2489} else if (e.key === 'Enter') {2490e.preventDefault();2491selectedElement = hoveredElement;2492state = 'CONFIGURING';2493showHighlight(selectedElement);2494clearAnnotations();2495showAnnotOverlay(selectedElement);2496showBar('configure');2497startScrollTracking();2498return;2499}2500if (next) {2501e.preventDefault();2502if (state === 'PICKING') {2503hoveredElement = next;2504} else {2505// CONFIGURING: re-select the new element and refresh the bar2506selectedElement = next;2507clearAnnotations();2508showAnnotOverlay(next);2509showBar('configure');2510startScrollTracking();2511}2512showHighlight(next);2513next.scrollIntoView({ block: 'nearest', behavior: 'smooth' });2514}2515return;2516}25172518if (state === 'CYCLING') {2519if (e.key === 'ArrowLeft') { e.preventDefault(); cycleVariant(-1); }2520if (e.key === 'ArrowRight') { e.preventDefault(); cycleVariant(1); }2521if (e.key === 'Enter') { e.preventDefault(); handleAccept(); }2522}2523}25242525function handleGo() {2526if (!selectedElement || state !== 'CONFIGURING') return;2527const input = document.getElementById(PREFIX + '-input');2528const prompt = input ? input.value.trim() : '';25292530// Commit any pending pin edit BEFORE we snapshot annotations.2531if (annotEditing) finalizeEditingPin();25322533currentSessionId = id8();2534expectedVariants = selectedCount;2535arrivedVariants = 0;2536visibleVariant = 0;25372538// Flip to GENERATING immediately so the bar morphs without waiting on2539// capture + upload. The event is emitted from captureAndEmit() once the2540// screenshot is uploaded (or capture fails — we still emit, just without2541// screenshotPath).2542const elForCapture = selectedElement;2543const captureRect = elForCapture.getBoundingClientRect();2544const snapshot = {2545comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })),2546strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })),2547};2548const basePayload = {2549type: 'generate', id: currentSessionId,2550action: selectedAction,2551freeformPrompt: prompt || undefined,2552count: selectedCount,2553pageUrl: location.pathname,2554element: extractContext(elForCapture),2555};2556if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;2557if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;25582559// Hide the interactive overlay so it doesn't linger during generation.2560hideAnnotOverlay();2561clearAnnotations();25622563state = 'GENERATING';2564showBar('generating');2565saveSession();2566sendCheckpoint('generate_started');2567writeScrollY(window.scrollY);2568if (variantObserver) variantObserver.disconnect();2569variantObserver = startVariantObserver(currentSessionId);2570console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId });2571startScrollLock(currentSessionId);25722573captureAndEmit(elForCapture, basePayload, snapshot, captureRect);2574}25752576// ---------------------------------------------------------------------------2577// Screenshot capture + upload2578// ---------------------------------------------------------------------------25792580let msLoadPromise = null;2581function loadModernScreenshot() {2582if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot);2583if (msLoadPromise) return msLoadPromise;2584msLoadPromise = new Promise((resolve, reject) => {2585const s = document.createElement('script');2586s.src = 'http://localhost:' + PORT + '/modern-screenshot.js';2587s.onload = () => resolve(window.modernScreenshot);2588s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); };2589document.head.appendChild(s);2590});2591return msLoadPromise;2592}25932594// Collect @font-face rules from every stylesheet on the page. Cross-origin2595// sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules2596// access, so modern-screenshot can't embed them on its own — the resulting2597// SVG falls back to system fonts and text re-wraps + renders with different2598// weight. We fetch the raw CSS text (CORS-permitted for these providers),2599// extract @font-face blocks, inline the referenced font files as base642600// data URIs (SVGs rasterized via canvas can't fetch external resources,2601// so URLs inside the SVG silently fail without this), and pass the result2602// to modern-screenshot as font.cssText.2603const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i;2604const FONT_MIME = {2605woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject',2606};2607function bufferToBase64(buf) {2608const bytes = new Uint8Array(buf);2609let binary = '';2610const CHUNK = 0x8000;2611for (let i = 0; i < bytes.length; i += CHUNK) {2612binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));2613}2614return btoa(binary);2615}2616async function inlineFontUrls(cssText) {2617const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g;2618const urls = new Set();2619let m;2620while ((m = urlRe.exec(cssText))) {2621if (FONT_EXT_RE.test(m[2])) urls.add(m[2]);2622}2623const map = new Map();2624await Promise.all([...urls].map(async (url) => {2625try {2626const res = await fetch(url);2627if (!res.ok) return;2628const buf = await res.arrayBuffer();2629const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2';2630const mime = FONT_MIME[ext] || 'application/octet-stream';2631map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf));2632} catch { /* skip; fall through to URL */ }2633}));2634return cssText.replace(urlRe, (orig, q, url) => {2635const data = map.get(url);2636return data ? 'url(' + q + data + q + ')' : orig;2637});2638}2639async function collectFontCssText() {2640const chunks = [];2641const fontFaceRe = /@font-face\s*\{[^}]*\}/g;2642for (const sheet of document.styleSheets) {2643try {2644const rules = sheet.cssRules;2645for (const rule of rules) {2646if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) {2647chunks.push(rule.cssText);2648}2649}2650} catch {2651if (!sheet.href) continue;2652try {2653const res = await fetch(sheet.href);2654if (!res.ok) continue;2655const text = await res.text();2656let m2;2657while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]);2658} catch { /* ignore; capture is best-effort */ }2659}2660}2661if (chunks.length === 0) return '';2662return inlineFontUrls(chunks.join('\n'));2663}26642665// True if `s` is a computed color string that renders as nothing2666// (explicit `transparent`, or `rgba(...)` with alpha 0).2667function isTransparentColor(s) {2668if (!s) return true;2669if (s === 'transparent') return true;2670const m = /rgba?\(([^)]+)\)/.exec(s);2671if (!m) return false;2672const parts = m[1].split(',').map((p) => p.trim());2673if (parts.length === 4) return parseFloat(parts[3]) === 0;2674return false;2675}26762677// modern-screenshot force-sets `background-color: X !important` on the2678// cloned root whenever `backgroundColor` is passed, clobbering the2679// element's own background. So we only pass it when the element is2680// genuinely transparent (no own color, no own image) — in that case2681// we resolve up the DOM to the nearest opaque ancestor so the capture2682// sits on the page's real background instead of rendering black.2683function resolveCanvasBackground(el) {2684const own = getComputedStyle(el);2685if (!isTransparentColor(own.backgroundColor)) return null;2686if (own.backgroundImage && own.backgroundImage !== 'none') return null;2687let node = el.parentElement;2688while (node) {2689const cs = getComputedStyle(node);2690if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;2691node = node.parentElement;2692}2693// The walk already passed through <body> and <html>; if they had been2694// opaque we would have returned. Falling through with the previous2695// `getComputedStyle(body).backgroundColor || …` chain is a trap: that2696// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that2697// never set its own bg, which is truthy and short-circuits the chain to2698// transparent-black — modern-screenshot then renders the capture on a2699// black canvas and the shader overlay flashes solid black during load.2700// The browser canvas defaults to white, so we do too.2701return '#ffffff';2702}27032704// Capture the element (with current annotations baked in) and return a PNG2705// Blob. Shared between the Go flow (uploads it to the server) and the2706// debug toggle (displays it as an overlay for side-by-side comparison).2707async function captureElementToBlob(el, snapshot, rect) {2708try { if (document.fonts?.ready) await document.fonts.ready; } catch {}2709const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);2710let annotNode = null;2711let savedPosition = null;2712if (hasAnnotations) {2713const pos = getComputedStyle(el).position;2714if (pos === 'static') {2715savedPosition = el.style.position;2716el.style.position = 'relative';2717}2718annotNode = buildAnnotationsForCapture(rect, snapshot);2719el.appendChild(annotNode);2720}2721try {2722const ms = await loadModernScreenshot();2723const fontCssText = await collectFontCssText();2724const backgroundColor = resolveCanvasBackground(el);2725return await ms.domToBlob(el, {2726scale: Math.min(window.devicePixelRatio || 1, 2),2727font: fontCssText ? { cssText: fontCssText } : undefined,2728...(backgroundColor ? { backgroundColor } : {}),2729});2730} finally {2731if (annotNode) annotNode.remove();2732if (savedPosition !== null) el.style.position = savedPosition;2733}2734}27352736async function captureAndEmit(el, basePayload, snapshot, rect) {2737let screenshotPath;2738let blob;2739try {2740blob = await captureElementToBlob(el, snapshot, rect);2741} catch (err) {2742console.warn('[impeccable] capture failed, proceeding without screenshot:', err);2743}2744// Light up the shader overlay the moment capture is ready — no reason to2745// wait for the upload to complete before the user sees something alive.2746if (blob && state === 'GENERATING') {2747showShaderOverlay(el, blob, rect);2748}2749// Only upload + forward the screenshot when annotations (comments/strokes)2750// are present. Without annotations the image is pure visual anchoring —2751// it biases the model toward the current rendering and works against the2752// three-distinct-directions brief.2753const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);2754if (blob && hasAnnotations) {2755try {2756const uploadRes = await fetch(2757'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) +2758'&eventId=' + encodeURIComponent(basePayload.id),2759{ method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob },2760);2761if (uploadRes.ok) {2762const { path: p } = await uploadRes.json();2763screenshotPath = p;2764} else {2765console.warn('[impeccable] annotation upload failed:', uploadRes.status);2766}2767} catch (err) {2768console.warn('[impeccable] annotation upload failed:', err);2769}2770}2771sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload);2772}27732774// ---------------------------------------------------------------------------2775// Shader overlay — renders the captured screenshot as a WebGL texture and2776// runs an editorial "ink-wash" fragment shader over it during generation.2777// A single rolling band sweeps top-to-bottom, desaturating + tinting magenta2778// and leaving a soft trail. Makes the wait feel like a letterpress scan2779// instead of a dead spinner.2780// ---------------------------------------------------------------------------27812782const SHADER_VS = `attribute vec2 a_position;2783attribute vec2 a_uv;2784varying vec2 v_uv;2785void main() {2786v_uv = a_uv;2787gl_Position = vec4(a_position, 0.0, 1.0);2788}`;27892790const SHADER_FS = `precision highp float;2791uniform sampler2D u_texture;2792uniform float u_time;2793uniform vec2 u_resolution;2794uniform vec3 u_accent;2795varying vec2 v_uv;27962797// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at2798// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean2799// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"2800// failure that reversed-edge smoothstep would give).2801float bandAt(float d, float leadW, float trailW) {2802float above = smoothstep(-leadW, 0.0, d);2803float below = 1.0 - smoothstep(0.0, trailW, d);2804return above * below;2805}28062807void main() {2808vec2 uv = v_uv;2809// Roller sweeps top-to-bottom with small overshoot so each cycle enters2810// and exits the element cleanly.2811float phase = fract(u_time / 3.4);2812float y = phase * 1.25 - 0.12;2813float band = bandAt(uv.y - y, 0.05, 0.32);28142815// Halftone cell grid (fixed ~10 px pitch).2816float cellPx = 10.0;2817vec2 gridUv = uv * u_resolution / cellPx;2818vec2 cellId = floor(gridUv);2819vec2 cellUv = fract(gridUv) - 0.5;2820vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;2821vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;2822float luma = dot(cellImg, vec3(0.299, 0.587, 0.114));2823// Darker cells → bigger magenta dots (classic risograph halftone curve).2824float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56;2825float dotMask = smoothstep(radius + 0.06, radius, length(cellUv));2826vec3 paper = vec3(0.975, 0.965, 0.955);2827vec3 dotLayer = mix(paper, u_accent, dotMask);28282829// Blend the halftone layer in where the roller is passing; leave the2830// element pristine elsewhere.2831vec3 base = texture2D(u_texture, uv).rgb;2832gl_FragColor = vec4(mix(base, dotLayer, band), 1.0);2833}`;28342835// Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350))2836const SHADER_ACCENT = [0.82, 0.16, 0.47];2837let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }28382839function compileShader(gl, type, source) {2840const sh = gl.createShader(type);2841gl.shaderSource(sh, source);2842gl.compileShader(sh);2843if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {2844const info = gl.getShaderInfoLog(sh);2845gl.deleteShader(sh);2846throw new Error('shader compile failed: ' + info);2847}2848return sh;2849}28502851function positionShaderOverlay() {2852if (!shaderState || !selectedElement) return;2853const r = selectedElement.getBoundingClientRect();2854Object.assign(shaderState.canvas.style, {2855top: r.top + 'px', left: r.left + 'px',2856width: r.width + 'px', height: r.height + 'px',2857});2858}28592860function hideShaderOverlay() {2861if (!shaderState) return;2862if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);2863if (shaderState.canvas) shaderState.canvas.remove();2864const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');2865try { lose?.loseContext(); } catch {}2866shaderState = null;2867}28682869async function showShaderOverlay(el, blob, rect) {2870hideShaderOverlay();2871if (!blob || !el) return;2872const canvas = document.createElement('canvas');2873canvas.id = PREFIX + '-shader';2874const dpr = Math.min(window.devicePixelRatio || 1, 2);2875canvas.width = Math.max(1, Math.floor(rect.width * dpr));2876canvas.height = Math.max(1, Math.floor(rect.height * dpr));2877Object.assign(canvas.style, {2878position: 'fixed',2879top: rect.top + 'px', left: rect.left + 'px',2880width: rect.width + 'px', height: rect.height + 'px',2881pointerEvents: 'none',2882zIndex: Z.bar - 1,2883});2884document.body.appendChild(canvas);28852886const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })2887|| canvas.getContext('experimental-webgl');2888if (!gl) {2889// WebGL unavailable — fall back to a plain <img> overlay so the user2890// still sees something meaningful during generation.2891canvas.remove();2892const img = document.createElement('img');2893img.src = URL.createObjectURL(blob);2894img.id = PREFIX + '-shader';2895// Copy positioning via cssText. Object.assign across CSSStyleDeclaration2896// throws in modern Chromium because the source's indexed properties2897// (style[0], [1], ...) are read-only and the engine forbids writing2898// them on the destination.2899img.style.cssText = canvas.style.cssText;2900img.style.outline = '2px dashed ' + C.brand;2901img.style.outlineOffset = '-2px';2902document.body.appendChild(img);2903shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };2904return;2905}29062907let program, texture;2908try {2909const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS);2910const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS);2911program = gl.createProgram();2912gl.attachShader(program, vs);2913gl.attachShader(program, fs);2914gl.linkProgram(program);2915if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {2916throw new Error('program link failed: ' + gl.getProgramInfoLog(program));2917}2918// Full-screen quad2919const buf = gl.createBuffer();2920gl.bindBuffer(gl.ARRAY_BUFFER, buf);2921gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([2922-1, -1, 0, 1,29231, -1, 1, 1,2924-1, 1, 0, 0,2925-1, 1, 0, 0,29261, -1, 1, 1,29271, 1, 1, 0,2928]), gl.STATIC_DRAW);2929const posLoc = gl.getAttribLocation(program, 'a_position');2930const uvLoc = gl.getAttribLocation(program, 'a_uv');2931gl.enableVertexAttribArray(posLoc);2932gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0);2933gl.enableVertexAttribArray(uvLoc);2934gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8);2935} catch (err) {2936console.warn('[impeccable] shader setup failed:', err);2937canvas.remove();2938return;2939}29402941// Upload the screenshot as a texture2942let bitmap;2943try {2944bitmap = await createImageBitmap(blob);2945} catch {2946// Safari fallback: go via a regular Image2947const imgUrl = URL.createObjectURL(blob);2948const img = new Image();2949img.src = imgUrl;2950await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });2951bitmap = img;2952URL.revokeObjectURL(imgUrl);2953}2954texture = gl.createTexture();2955gl.bindTexture(gl.TEXTURE_2D, texture);2956gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);2957gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);2958gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);2959gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);2960gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);2961gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap);2962if (bitmap.close) bitmap.close();29632964const uTime = gl.getUniformLocation(program, 'u_time');2965const uRes = gl.getUniformLocation(program, 'u_resolution');2966const uAccent = gl.getUniformLocation(program, 'u_accent');2967const uTex = gl.getUniformLocation(program, 'u_texture');2968const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;29692970shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };2971function frame() {2972if (!shaderState) return;2973const elapsed = (performance.now() - shaderState.startTime) / 1000;2974const t = shaderState.reduced ? 0.0 : elapsed;2975gl.viewport(0, 0, canvas.width, canvas.height);2976gl.useProgram(program);2977gl.activeTexture(gl.TEXTURE0);2978gl.bindTexture(gl.TEXTURE_2D, texture);2979gl.uniform1i(uTex, 0);2980gl.uniform1f(uTime, t);2981gl.uniform2f(uRes, canvas.width, canvas.height);2982gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]);2983gl.drawArrays(gl.TRIANGLES, 0, 6);2984shaderState.rafId = requestAnimationFrame(frame);2985}2986frame();2987}29882989function handleAccept() {2990if (!currentSessionId || arrivedVariants === 0) return;2991const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId);2992if (domVisibleVariant > 0) visibleVariant = domVisibleVariant;2993const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };2994if (Object.keys(paramsCurrentValues).length > 0) {2995acceptPayload.paramValues = { ...paramsCurrentValues };2996}2997// The accepted variant is already the only visible child of the wrapper2998// (all other variants are display:none). HMR from the source rewrite will2999// replace the wrapper imminently. Don't eagerly replaceChild here — React3000// reconciliation races with our mutation and throws NotFoundError in Next3001// 16 / Turbopack. Schedule a fallback that runs the manual swap only if3002// HMR hasn't cleaned up by then (keeps static-server flows working).3003const acceptedSessionId = currentSessionId;3004const acceptedVariant = visibleVariant;30053006state = 'SAVING';3007updateBarContent('saving');30083009sendEvent(acceptPayload, { throwOnError: true })3010.then(() => {3011markSessionHandled();3012confirmAcceptAfterReceipt();3013})3014.catch(() => {3015state = 'CYCLING';3016updateBarContent('cycling');3017showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);3018});30193020function confirmAcceptAfterReceipt() {3021state = 'CONFIRMED';3022updateBarContent('confirmed');3023scheduleAcceptCleanup();3024}30253026function scheduleAcceptCleanup() {3027setTimeout(function() {3028hideBar();3029hideHighlight();3030stopScrollTracking();3031if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }3032stopScrollLock();3033clearScrollY();3034clearSession();3035selectedElement = null;3036currentSessionId = null;3037selectedAction = 'impeccable';3038state = 'PICKING';3039}, 1800);30403041// Static-server / no-HMR fallback: if the wrapper is still around 2s after3042// the cleanup above, swap it out manually. By now React has either moved3043// on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`3044// div (with display:contents) so @scope rules anchored to the variant3045// attribute keep matching until reload replaces it with the carbonize block.3046setTimeout(function() {3047const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');3048if (!wrapper) return;3049const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');3050if (accepted && accepted.firstElementChild) {3051const parent = wrapper.parentElement;3052if (!parent) return;3053accepted.style.display = 'contents';3054parent.replaceChild(accepted, wrapper);3055}3056}, 2000);3057}3058}30593060function handleDiscard() {3061if (!currentSessionId) return;3062sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true })3063.then(() => {3064markSessionHandled();3065cleanup();3066})3067.catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000));3068}30693070// ---------------------------------------------------------------------------3071// Session persistence via live-browser-session.js3072// ---------------------------------------------------------------------------3073// Survives page reloads, browser close/reopen, HMR, and accidental refreshes.30743075function saveSession() {3076if (!currentSessionId) return;3077// NOTE: scrollY is stored under a separate key (writeScrollY). Storing3078// it here would overwrite the Go-time value every time state changes.3079sessionState.saveSession({3080id: currentSessionId,3081state,3082action: selectedAction,3083count: selectedCount,3084expected: expectedVariants,3085arrived: arrivedVariants,3086visible: visibleVariant,3087});3088}30893090function loadSession() {3091return sessionState.loadSession();3092}30933094function clearSession() {3095sessionState.clearSession();3096}30973098/** Mark session as handled (accepted/discarded). The agent will clean up3099* the source, but until it does the wrapper is still in the HTML. This3100* prevents resumeSession from picking it up again after reload. */3101function markSessionHandled() {3102if (!currentSessionId) return;3103sessionState.markHandled(currentSessionId);3104}31053106function isSessionHandled(id) {3107return sessionState.isHandled(id);3108}31093110function clearHandled() {3111sessionState.clearHandled();3112}31133114function cleanup() {3115// Hide the wrapper immediately so variants disappear. DON'T structurally3116// mutate the DOM yet — HMR from the agent's source rewrite is on its way,3117// and a manual replaceChild under React causes NotFoundError when the3118// reconciler later tries to remove a wrapper we already removed.3119// Schedule a 2s fallback that does the manual swap only if HMR hasn't3120// replaced the wrapper by then (keeps static-server / no-HMR flows alive).3121const cleanupSessionId = currentSessionId;3122if (cleanupSessionId) {3123const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');3124if (wrapper) wrapper.style.display = 'none';3125}3126setTimeout(function() {3127if (!cleanupSessionId) return;3128const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');3129if (!wrapper) return;3130const orig = wrapper.querySelector('[data-impeccable-variant="original"]');3131if (orig) {3132const content = orig.firstElementChild;3133if (content) {3134wrapper.parentElement.replaceChild(content, wrapper);3135return;3136}3137}3138wrapper.remove();3139}, 2000);3140hideBar();3141hideHighlight();3142stopScrollTracking();3143if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }3144stopScrollLock();3145clearScrollY();3146clearSession();3147selectedElement = null;3148currentSessionId = null;3149selectedAction = 'impeccable';3150state = 'PICKING';3151}31523153// ---------------------------------------------------------------------------3154// Toast3155// ---------------------------------------------------------------------------31563157function showToast(message, duration) {3158if (toastEl) toastEl.remove();3159// Stack the toast above the global bar (which sits at bottom:14px) so3160// the two never overlap. Read the bar's actual rect — its height varies3161// with hover-expanded labels — and fall back to a sensible default3162// when the bar isn't mounted yet.3163const barRect = globalBarEl?.getBoundingClientRect();3164const barTopFromBottom = barRect && barRect.height > 03165? Math.max(16, window.innerHeight - barRect.top + 12)3166: 16;3167toastEl = el('div', {3168position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',3169transform: 'translateX(-50%) translateY(8px)',3170background: C.ink, color: C.white,3171fontFamily: FONT, fontSize: '12px',3172padding: '8px 16px', borderRadius: '8px',3173zIndex: Z.toast, opacity: '0',3174transition: 'opacity 0.25s ' + EASE + ', transform 0.25s ' + EASE,3175pointerEvents: 'none', maxWidth: '420px', textAlign: 'center',3176});3177toastEl.id = PREFIX + '-toast';3178toastEl.textContent = message;3179document.body.appendChild(toastEl);3180requestAnimationFrame(() => {3181toastEl.style.opacity = '1';3182toastEl.style.transform = 'translateX(-50%) translateY(0)';3183});3184setTimeout(() => {3185if (toastEl) {3186toastEl.style.opacity = '0';3187toastEl.style.transform = 'translateX(-50%) translateY(8px)';3188setTimeout(() => { if (toastEl) { toastEl.remove(); toastEl = null; } }, 250);3189}3190}, duration);3191}31923193// ---------------------------------------------------------------------------3194// Init3195// ---------------------------------------------------------------------------31963197// Resume an active variant session after HMR/page reload.3198// If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote3199// variants before HMR fired. Pick up where we left off.3200function resumeSession() {3201const wrapper = document.querySelector('[data-impeccable-variants]');3202if (!wrapper) { clearSession(); clearHandled(); return false; }32033204const sessionId = wrapper.dataset.impeccableVariants;32053206// Don't resume if this session was already accepted/discarded3207if (isSessionHandled(sessionId)) return false;32083209currentSessionId = sessionId;3210expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0');3211const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');3212arrivedVariants = variants.length;32133214// Restore state from localStorage if available3215const saved = loadSession();3216if (saved && saved.id === sessionId) {3217visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0);3218if (saved.action) selectedAction = saved.action;3219if (saved.count) selectedCount = saved.count;3220} else {3221visibleVariant = arrivedVariants > 0 ? 1 : 0;3222}32233224// Find the visible variant's content element for highlight positioning.3225// Try the visible variant first, fall back to the original's content.3226const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;3227const origEl = pickVariantContent(wrapper, 'original');3228selectedElement = visEl || origEl || wrapper.parentElement;32293230// Set display state BEFORE starting observer (avoid triggering it)3231if (visibleVariant > 0) showVariantInDOM(currentSessionId, visibleVariant);32323233state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';3234showBar(state === 'CYCLING' ? 'cycling' : 'generating');3235startScrollTracking();3236// Build the params panel for the restored visible variant. Previously3237// this was missed on page-reload resume: showVariantInDOM above fires3238// refreshParamsPanel, but state was still IDLE at that moment so it3239// hid. Now that state is CYCLING, re-fire.3240if (state === 'CYCLING') refreshParamsPanel();3241saveSession();3242queueCheckpoint('browser_resumed');32433244// Start observing for more variants AFTER initial setup3245if (variantObserver) variantObserver.disconnect();3246variantObserver = startVariantObserver(currentSessionId);32473248// Hold the target at its saved viewport top through any subsequent3249// HMR patches, variant inserts, or cycle swaps.3250startScrollLock(currentSessionId, readScrollY());32513252// If we reloaded mid-generation (Bun's HTML HMR destroys the shader3253// canvas), re-capture the original's content and restart the shader so3254// the wait doesn't go dead.3255if (state === 'GENERATING' && origEl) {3256(async () => {3257try {3258const rect = origEl.getBoundingClientRect();3259if (rect.width === 0 || rect.height === 0) return;3260const blob = await captureElementToBlob(origEl, null, rect);3261if (blob && state === 'GENERATING') {3262showShaderOverlay(origEl, blob, rect);3263}3264} catch (err) {3265console.warn('[impeccable] shader resume failed:', err);3266}3267})();3268}3269return true;3270}32713272// ---------------------------------------------------------------------------3273// Global bar (always visible at bottom)3274// ---------------------------------------------------------------------------32753276let globalBarEl = null;3277let detectActive = false;3278let pickActive = true;3279let detectCount = 0;3280let detectScriptLoaded = false;32813282// Theme-aware color palette for the global bar. We detect the page's3283// ambient background and invert — dark bar on light pages, light bar on3284// dark pages. This keeps the bar from fighting with the host design.3285function detectPageTheme() {3286try {3287// Dev override: set localStorage 'impeccable-dev-theme' to 'light' or3288// 'dark' to preview the opposite palette without actually changing the3289// page bg. Used for screenshots and theme QA.3290const override = localStorage.getItem('impeccable-dev-theme');3291if (override === 'light' || override === 'dark') return override;32923293// Walk body → html, taking the first opaque background. The browser's3294// default body / html background is `rgba(0, 0, 0, 0)`, which a naive3295// regex would read as black and mislabel a perfectly white page as3296// dark. Honoring alpha avoids that — and falling through to <html>3297// catches the common pattern of a bg only on <html> (or only on body).3298function readOpaque(el) {3299if (!el) return null;3300const bg = getComputedStyle(el).backgroundColor;3301const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);3302if (!m) return null;3303const alpha = m[4] == null ? 1 : parseFloat(m[4]);3304if (alpha < 0.5) return null; // transparent / nearly transparent → skip3305return [+m[1], +m[2], +m[3]];3306}33073308const rgb = readOpaque(document.body) || readOpaque(document.documentElement);3309// Both transparent → fall back to the browser's effective canvas color.3310// White is the universal default; only one in a thousand sites swaps it3311// via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets3312// us catch that case.3313if (!rgb) {3314return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';3315}3316const [r, g, b] = rgb;3317// Perceptual luminance (Rec. 709)3318const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;3319return L > 0.55 ? 'light' : 'dark';3320} catch { return 'light'; }3321}33223323function barPaletteForTheme(theme) {3324if (theme === 'dark') {3325// Light bar on dark page3326return {3327surface: 'oklch(98% 0 0 / 0.92)',3328surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm3329hairline: 'oklch(70% 0 0 / 0.35)',3330text: 'oklch(15% 0 0)',3331textDim: 'oklch(45% 0 0)',3332accent: 'oklch(60% 0.25 350)',3333accentSoft: 'oklch(60% 0.25 350 / 0.18)',3334mark: 'oklch(98% 0 0)', // logo mark fill3335markText: 'oklch(15% 0 0)', // logo "/" color3336exitHover: 'oklch(85% 0 0 / 0.5)',3337};3338}3339// Dark bar on light page. Bar is a warm charcoal, logo slab is much3340// deeper so the rounded-right shape reads as a clear sculpted mark.3341return {3342surface: 'oklch(26% 0 0 / 0.94)',3343surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover3344hairline: 'oklch(42% 0 0 / 0.5)',3345text: 'oklch(96% 0 0)',3346textDim: 'oklch(72% 0 0)',3347accent: 'oklch(72% 0.22 350)',3348accentSoft: 'oklch(72% 0.22 350 / 0.22)',3349mark: 'oklch(8% 0 0)',3350markText: 'oklch(96% 0 0)',3351exitHover: 'oklch(36% 0 0 / 0.6)',3352};3353}33543355// Impeccable logo mark — matches the site-header SVG (rounded square + "/").3356function brandMarkSvg(fill, ink, size = 18) {3357return `<svg width="${size}" height="${size}" viewBox="0 0 32 32" aria-hidden="true">3358<rect width="32" height="32" rx="7" fill="${fill}"/>3359<text x="16" y="24" font-family="system-ui, -apple-system, sans-serif" font-size="22" font-weight="500" fill="${ink}" text-anchor="middle">/</text>3360</svg>`;3361}33623363function initGlobalBar() {3364const theme = detectPageTheme();3365const P = barPaletteForTheme(theme);33663367// Custom focus-visible for bar buttons. Browser default is a heavy3368// blue ring that looks jarring on the dark capsule. Replace with a3369// soft accent-tinted inner ring that respects the bar's palette.3370if (!document.getElementById(PREFIX + '-bar-focus-style')) {3371const s = document.createElement('style');3372s.id = PREFIX + '-bar-focus-style';3373s.textContent =3374'#' + PREFIX + '-global-bar button:focus { outline: none; }' +3375'#' + PREFIX + '-global-bar button:focus-visible {' +3376' outline: none;' +3377' box-shadow: 0 0 0 2px ' + P.accentSoft + ', 0 0 0 3px ' + P.accent + ';' +3378'}';3379document.head.appendChild(s);3380}33813382globalBarEl = el('div', {3383position: 'fixed', bottom: '14px', left: '50%',3384transform: 'translateX(-50%) translateY(20px)',3385zIndex: Z.bar + 5,3386display: 'flex', alignItems: 'stretch',3387gap: '2px',3388background: P.surface,3389backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',3390border: '1px solid ' + P.hairline,3391borderRadius: '10px',3392boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)',3393fontFamily: FONT, fontSize: '12px', lineHeight: '1',3394opacity: '0',3395overflow: 'hidden', // clip the full-bleed brand mark to the bar radius3396transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,3397});3398globalBarEl.id = PREFIX + '-global-bar';3399globalBarEl.dataset.theme = theme;34003401// Brand mark — fills bar height on the left. Left side inherits the bar's3402// rounded corner via overflow:hidden; right side is a clean hard edge since3403// the near-black/charcoal contrast does the shape-defining work.3404const brand = el('span', {3405display: 'inline-flex', alignItems: 'center', justifyContent: 'center',3406alignSelf: 'stretch',3407padding: '0 12px 0 14px',3408background: P.mark,3409color: P.markText,3410fontFamily: 'system-ui, -apple-system, sans-serif',3411fontWeight: '500',3412fontSize: '18px', lineHeight: '1',3413});3414brand.textContent = '/';3415brand.title = 'Impeccable';3416globalBarEl.appendChild(brand);34173418// Inner wrapper: holds the toggles with normal bar padding.3419const inner = el('div', {3420display: 'flex', alignItems: 'center',3421padding: '4px 5px', gap: '2px',3422});3423inner.id = PREFIX + '-global-bar-inner';3424globalBarEl.appendChild(inner);34253426// --- button factory: icon-only at rest, label slides in on hover/active ---3427function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {3428const b = el('button', {3429position: 'relative',3430display: 'inline-flex', alignItems: 'center',3431padding: '6px 8px', borderRadius: '7px',3432border: 'none', background: 'transparent',3433color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',3434cursor: 'pointer',3435transition: 'background 0.15s ease, color 0.15s ease',3436whiteSpace: 'nowrap', overflow: 'hidden',3437});3438b.id = id;3439b.title = ariaLabel || label || '';3440b.setAttribute('aria-label', ariaLabel || label || '');3441b.innerHTML = svg + (label3442? `<span class="icon-btn-label" style="display:inline-block;max-width:0;opacity:0;margin-left:0;overflow:hidden;font-family:${labelFont || FONT};transition:max-width 0.25s ${EASE}, opacity 0.2s ease, margin-left 0.25s ${EASE};">${label}</span>`3443: '');3444const labelEl = b.querySelector('.icon-btn-label');3445const expand = () => {3446if (!labelEl) return;3447labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px';3448};3449const collapse = () => {3450if (!labelEl || b.dataset.active === 'true') return;3451labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0';3452};3453// Per-button hover only changes color (no layout). The label expand/3454// collapse is driven by the bar-level mouseenter/mouseleave so moving3455// the mouse between adjacent buttons doesn't trigger per-button width3456// thrashing — the whole bar grows once and shrinks once.3457b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });3458b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });3459b.addEventListener('click', onClick);3460b._expandLabel = expand;3461b._collapseLabel = collapse;3462return b;3463}34643465// Pick toggle — starts active (primary intent when entering live mode).3466const pickBtn = makeIconBtn({3467id: PREFIX + '-pick-toggle',3468svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',3469label: 'Pick',3470ariaLabel: 'Pick element',3471onClick: () => togglePick(),3472});3473pickBtn.style.background = P.accentSoft;3474pickBtn.style.color = P.accent;3475pickBtn.dataset.active = 'true';3476pickBtn._expandLabel();3477inner.appendChild(pickBtn);34783479// Detect toggle3480const detectBtn = makeIconBtn({3481id: PREFIX + '-detect-toggle',3482svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>',3483label: 'Detect',3484ariaLabel: 'Detect anti-patterns',3485onClick: () => toggleDetect(),3486});3487const detectBadge = el('span', {3488fontSize: '10px', fontWeight: '600',3489padding: '0px 5px', borderRadius: '7px', lineHeight: '16px',3490background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)',3491display: 'none', fontFamily: MONO, marginLeft: '4px',3492});3493detectBadge.id = PREFIX + '-detect-badge';3494detectBtn.appendChild(detectBadge);3495inner.appendChild(detectBtn);34963497// DESIGN.md panel toggle — quartet of color squares as the mark.3498const designBtn = makeIconBtn({3499id: PREFIX + '-design-toggle',3500svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px ${P.hairline};flex-shrink:0">3501<span style="background:oklch(60% 0.25 350)"></span>3502<span style="background:oklch(60% 0.15 45)"></span>3503<span style="background:oklch(55% 0.12 250)"></span>3504<span style="background:oklch(30% 0 0)"></span>3505</span>`,3506label: 'DESIGN.md',3507ariaLabel: 'Toggle DESIGN.md panel',3508labelFont: MONO,3509onClick: () => toggleDesignPanel(),3510});3511inner.appendChild(designBtn);35123513// Thin divider before the exit button3514const divider = el('span', {3515width: '1px', height: '18px',3516background: P.hairline,3517margin: '0 4px 0 2px',3518});3519inner.appendChild(divider);35203521// Exit × on the right — intentionally subtle (textDim at rest, text on3522// hover) so it sits behind the active toggles in visual hierarchy.3523//3524// Explicit padding + box-sizing here is load-bearing: a host page like3525// `button { padding: 0.5rem 1rem; }` (very common in resets) would3526// otherwise inflate this 24x24 button into 56x40 and push the SVG out3527// of the visible bar — the X stays invisible even though the styles in3528// DevTools look fine. Every other chrome button sets padding inline;3529// this one needed it too.3530const exitBtn = el('button', {3531display: 'inline-flex', alignItems: 'center', justifyContent: 'center',3532padding: '0', boxSizing: 'border-box',3533width: '24px', height: '24px', borderRadius: '6px',3534border: 'none', background: 'transparent',3535color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',3536cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',3537});3538exitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/></svg>';3539exitBtn.title = 'Exit live mode';3540exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });3541exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });3542exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });3543inner.appendChild(exitBtn);35443545// Bar-level hover: expand every toggle's label at once; collapse on leave.3546// Buttons with dataset.active="true" ignore collapse (their label stays).3547const toggles = [pickBtn, detectBtn, designBtn];3548globalBarEl.addEventListener('mouseenter', () => {3549toggles.forEach((t) => t._expandLabel && t._expandLabel());3550});3551globalBarEl.addEventListener('mouseleave', () => {3552toggles.forEach((t) => t._collapseLabel && t._collapseLabel());3553});35543555document.body.appendChild(globalBarEl);3556defangOutsideHandlers(globalBarEl);35573558requestAnimationFrame(() => {3559globalBarEl.style.opacity = '1';3560globalBarEl.style.transform = 'translateX(-50%) translateY(0)';3561});35623563// Listen for detection results AND ready signal3564window.addEventListener('message', onDetectMessage);3565}35663567function updateGlobalBarState() {3568const detectToggle = document.getElementById(PREFIX + '-detect-toggle');3569const detectBadge = document.getElementById(PREFIX + '-detect-badge');3570const pickToggle = document.getElementById(PREFIX + '-pick-toggle');3571const designToggle = document.getElementById(PREFIX + '-design-toggle');3572const theme = globalBarEl?.dataset.theme || 'light';3573const P = barPaletteForTheme(theme);35743575// Sync one toggle's active state, colors, and slide-label visibility.3576function sync(btn, active) {3577if (!btn) return;3578btn.style.background = active ? P.accentSoft : 'transparent';3579btn.style.color = active ? P.accent : P.textDim;3580btn.dataset.active = active ? 'true' : 'false';3581if (active && btn._expandLabel) btn._expandLabel();3582else if (!active && btn._collapseLabel) btn._collapseLabel();3583}3584sync(pickToggle, pickActive);3585sync(detectToggle, detectActive);3586sync(designToggle, designState.open);35873588// If the bar is currently under the cursor, keep all labels expanded —3589// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)3590// would collapse its label while the user's mouse is still on the bar.3591if (globalBarEl && globalBarEl.matches(':hover')) {3592[pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());3593}35943595if (detectBadge) {3596detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';3597detectBadge.textContent = detectCount;3598}35993600// When pick is active, make detect overlays click-through so the picker works3601document.querySelectorAll('.impeccable-overlay').forEach(o => {3602o.style.pointerEvents = pickActive ? 'none' : '';3603});3604}36053606let detectReady = false; // true once detect script posts 'impeccable-ready'3607let detectPendingScan = false; // scan requested before script was ready36083609function toggleDetect() {3610detectActive = !detectActive;3611updateGlobalBarState();36123613if (detectActive) {3614if (!detectScriptLoaded) {3615detectPendingScan = true;3616loadDetectScript();3617} else if (detectReady) {3618window.postMessage({ source: 'impeccable-command', action: 'scan' }, '*');3619} else {3620detectPendingScan = true;3621}3622} else {3623window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');3624detectCount = 0;3625updateGlobalBarState();3626}3627}36283629function togglePick() {3630pickActive = !pickActive;3631updateGlobalBarState();36323633if (!pickActive) {3634// Disabling pick clears any in-flight selection and UI: highlight,3635// contextual bar, selectedElement. Otherwise a stale selection sits3636// on screen with no obvious way to dismiss.3637hideHighlight();3638hideBar();3639hideActionPicker();3640selectedElement = null;3641if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE';3642} else {3643if (state === 'IDLE') state = 'PICKING';3644}3645}36463647function loadDetectScript() {3648if (detectScriptLoaded) return;3649detectScriptLoaded = true;3650const s = document.createElement('script');3651s.src = 'http://localhost:' + PORT + '/detect.js';3652s.dataset.impeccableExtension = 'true';3653document.head.appendChild(s);3654}36553656function onDetectMessage(e) {3657if (!e.data || typeof e.data.source !== 'string') return;3658// Detection script is loaded and ready3659if (e.data.source === 'impeccable-ready') {3660detectReady = true;3661if (detectPendingScan && detectActive) {3662detectPendingScan = false;3663window.postMessage({ source: 'impeccable-command', action: 'scan' }, '*');3664}3665}3666// Scan results arrived3667if (e.data.source === 'impeccable-results') {3668detectCount = e.data.count || 0;3669updateGlobalBarState();3670}3671}36723673/** Full teardown: remove all UI, disconnect SSE, clean up. */3674function teardown() {3675cleanup();3676hideBar();3677if (globalBarEl) {3678globalBarEl.style.transform = 'translateY(100%)';3679setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300);3680}3681if (highlightEl) { highlightEl.remove(); highlightEl = null; }3682if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }3683if (barEl) { barEl.remove(); barEl = null; }3684if (pickerEl) { pickerEl.remove(); pickerEl = null; }3685if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }3686if (evtSource) { evtSource.close(); evtSource = null; }3687document.removeEventListener('mousemove', handleMouseMove, true);3688document.removeEventListener('click', handleClick, true);3689document.removeEventListener('keydown', handleKeyDown, true);3690window.removeEventListener('message', onDetectMessage);3691// Remove detection overlays3692window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');3693state = 'IDLE';3694window.__IMPECCABLE_LIVE_INIT__ = false;3695console.log('[impeccable] Live mode exited.');3696}36973698// ---------------------------------------------------------------------------3699// Design System Panel — visualizes the project's .impeccable/design.json sidecar3700// ---------------------------------------------------------------------------37013702const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';3703const DESIGN_PANEL_WIDTH = 440;37043705let designHost = null;3706let designShadow = null;3707let designState = {3708open: false,3709tab: 'visual', // 'visual' | 'raw'3710parsed: null, // parseDesignMd output (frontmatter + body sections)3711sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative)3712hasMd: false,3713hasSidecar: false,3714present: null, // true/false once fetch resolves3715raw: null, // raw DESIGN.md for the raw tab3716mdNewerThanJson: false, // stale-hint flag3717loading: false,3718error: null,3719collapsed: { // narrative-section accordion state3720rules: true, dosdonts: true, overview: true,3721},3722};37233724function loadDesignPrefs() {3725// `open` is intentionally NOT persisted — the panel always starts closed3726// so live mode doesn't auto-slide a big panel over the page on startup.3727try {3728const raw = localStorage.getItem(DESIGN_PREFS_KEY);3729if (!raw) return;3730const prefs = JSON.parse(raw);3731if (prefs.tab === 'visual' || prefs.tab === 'raw') designState.tab = prefs.tab;3732if (prefs.collapsed && typeof prefs.collapsed === 'object') {3733Object.assign(designState.collapsed, prefs.collapsed);3734}3735} catch { /* ignore */ }3736}37373738function saveDesignPrefs() {3739try {3740localStorage.setItem(DESIGN_PREFS_KEY, JSON.stringify({3741tab: designState.tab,3742collapsed: designState.collapsed,3743}));3744} catch { /* ignore */ }3745}37463747function initDesignPanel() {3748designHost = document.createElement('div');3749designHost.id = PREFIX + '-design-host';3750Object.assign(designHost.style, {3751position: 'fixed', top: '0', left: '0',3752width: '0', height: '0',3753zIndex: String(Z.bar + 10),3754pointerEvents: 'none',3755});3756designShadow = designHost.attachShadow({ mode: 'open' });37573758const style = document.createElement('style');3759// Theme-match the bar: dark chrome on light pages, light chrome on dark pages.3760const theme = detectPageTheme();3761style.textContent = designPanelCss(barPaletteForTheme(theme));3762designShadow.appendChild(style);37633764const root = document.createElement('div');3765root.className = 'root';3766designShadow.appendChild(root);37673768document.body.appendChild(designHost);3769// The host is pointer-events: none; the panel inside the shadow DOM3770// manages its own auto/none. Events bubble through the shadow boundary,3771// so attaching here silences host-page outside-interaction handlers3772// without touching the host's click-through behavior.3773defangOutsideHandlers(designHost, { setPointerEvents: false });37743775loadDesignPrefs();3776renderDesignChrome();3777if (designState.open) {3778fetchDesignSystem();3779}3780}37813782// Neutral panel palette — deliberately NOT Impeccable-branded. The panel is3783// a viewer of the project's design system, not an Impeccable surface.3784const DP = {3785canvas: 'oklch(94% 0 0)', // panel background3786tile: 'oklch(98.5% 0 0)', // card-on-canvas3787tileAlt: 'oklch(96% 0 0)', // subtler tile for inner surfaces3788ink: 'oklch(15% 0 0)',3789ink2: 'oklch(35% 0 0)',3790meta: 'oklch(55% 0 0)',3791hairline: 'oklch(88% 0 0)',3792hairlineSoft: 'oklch(92% 0 0)',3793amber: 'oklch(70% 0.13 65)', // stale-hint accent3794amberBg: 'oklch(95% 0.05 80)',3795};37963797function designPanelCss(BP) {3798// BP = bar palette (theme-aware, matches the global bar).3799// DP = internal content palette (neutral, so tiles render colors true).3800return `3801:host, .root { all: initial; }3802.root {3803font-family: ${FONT};3804color: ${DP.ink};3805pointer-events: none;3806}3807.root * { box-sizing: border-box; }3808button { font: inherit; color: inherit; }38093810/* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */3811.panel {3812position: fixed; top: 12px; bottom: 72px; right: 12px;3813width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px);3814background: ${BP.surface};3815border: 1px solid ${BP.hairline};3816border-radius: 14px;3817backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);3818box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08);3819display: flex; flex-direction: column;3820transform: translateX(calc(100% + 24px));3821opacity: 0;3822transition: transform 0.35s ${EASE}, opacity 0.25s ${EASE};3823pointer-events: none;3824overflow: hidden;3825}3826.panel[data-open="true"] { transform: translateX(0); opacity: 1; pointer-events: auto; }38273828.panel-header {3829display: flex; align-items: center; gap: 10px;3830padding: 10px 10px 10px 14px;3831background: transparent;3832border-bottom: 1px solid ${BP.hairline};3833}3834.panel-title {3835flex: 1; min-width: 0;3836font-family: ${MONO};3837font-size: 11.5px; font-weight: 600;3838letter-spacing: 0.02em;3839color: ${BP.text};3840white-space: nowrap; overflow: hidden; text-overflow: ellipsis;3841}3842.panel-close {3843border: none; background: transparent; color: ${BP.textDim};3844width: 26px; height: 26px; border-radius: 7px;3845display: inline-flex; align-items: center; justify-content: center;3846cursor: pointer; transition: background 0.15s ease, color 0.15s ease;3847}3848.panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; }38493850.tabs {3851display: inline-flex; padding: 2px;3852background: ${BP.hairline};3853border-radius: 7px;3854gap: 2px;3855}3856.tab {3857border: none; background: transparent;3858padding: 4px 10px; border-radius: 5px;3859font-family: ${MONO};3860font-size: 10px; font-weight: 600; letter-spacing: 0.08em;3861text-transform: uppercase;3862color: ${BP.textDim}; cursor: pointer;3863transition: background 0.15s ease, color 0.15s ease;3864}3865.tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; }38663867.panel-body {3868flex: 1; overflow-y: auto;3869padding: 12px 12px 20px;3870background: ${DP.canvas};3871scrollbar-width: thin;3872scrollbar-color: ${DP.hairline} transparent;3873}3874.panel-body::-webkit-scrollbar { width: 8px; }3875.panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; }38763877/* --- States --- */3878.empty, .loading, .error {3879margin: 16px 4px;3880padding: 28px 20px; text-align: center;3881background: ${DP.tile}; border-radius: 14px;3882color: ${DP.ink2}; font-size: 13px; line-height: 1.55;3883}3884.empty strong { color: ${DP.ink}; display: block; margin-bottom: 6px; font-size: 14px; }3885.empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; }3886.error { color: oklch(45% 0.15 25); }38873888/* --- Stale hint --- */3889.stale {3890display: flex; align-items: center; gap: 8px;3891margin: 8px 4px 12px;3892padding: 8px 12px;3893background: ${DP.amberBg};3894border-radius: 10px;3895font-size: 11.5px; color: ${DP.ink2};3896}3897.stale-dot { width: 8px; height: 8px; border-radius: 50%; background: ${DP.amber}; flex-shrink: 0; }3898.stale-text { flex: 1; min-width: 0; }3899.stale-text strong { color: ${DP.ink}; font-weight: 600; }39003901/* --- Parsed-md fallback banner --- */3902.parsed-md-cta {3903margin: 8px 4px 14px;3904padding: 14px 16px;3905background: ${DP.tile};3906border: 1px dashed ${DP.hairline};3907border-radius: 12px;3908font-size: 12px; color: ${DP.ink2}; line-height: 1.55;3909}3910.parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; }3911.parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; }39123913/* --- Tile primitives --- */3914.tile {3915position: relative;3916background: ${DP.tile};3917border-radius: 16px;3918padding: 16px;3919margin: 0 4px 10px;3920}3921.tile-row { margin: 0 4px 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }3922.tile-row .tile { margin: 0; }3923.tile-meta {3924display: flex; align-items: baseline; justify-content: space-between;3925gap: 10px;3926font-family: ${MONO};3927font-size: 10px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase;3928color: ${DP.meta};3929}3930.tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; }39313932/* --- Color tile --- */3933.c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; }3934.c-tile:hover { transform: translateY(-1px); }3935.c-hero {3936height: 72px; border-radius: 10px; margin-top: 10px;3937box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.05);3938}3939.c-ramp {3940display: flex; gap: 0; height: 14px; border-radius: 4px; overflow: hidden;3941margin-top: 8px;3942box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.04);3943}3944.c-ramp > span { flex: 1; }3945.c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; }39463947/* --- Type tile --- */3948.t-tile { }3949.t-specimen {3950margin: 4px 0 6px;3951color: ${DP.ink};3952line-height: 0.9;3953}3954.t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; }3955.t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; }39563957/* --- Shadow tile --- */3958.s-tile { }3959.s-surface {3960height: 60px; margin: 8px 2px 10px;3961background: ${DP.tile};3962border-radius: 10px;3963}3964.s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; }3965.s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; }39663967/* --- Radii strip --- */3968.r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; }3969.r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; }3970.r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); }3971.r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; }3972.r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; }39733974/* --- Component tile (hosts live primitives) --- */3975.cmp-tile { }3976.cmp-stage {3977margin: 12px -4px 0;3978padding: 18px 16px 10px;3979border-top: 1px solid ${DP.hairlineSoft};3980display: flex; flex-direction: column; align-items: center; justify-content: center;3981gap: 14px;3982min-height: 68px;3983}3984.cmp-stage + .cmp-stage { border-top: 1px dashed ${DP.hairlineSoft}; }3985.cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; }3986.cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; }39873988/* --- Collapsible --- */3989.coll {3990margin: 0 4px 8px;3991background: ${DP.tile};3992border-radius: 12px;3993overflow: hidden;3994}3995.coll-head {3996display: flex; align-items: center; gap: 10px;3997width: 100%;3998padding: 12px 14px;3999background: transparent; border: none;4000cursor: pointer; text-align: left;4001font-family: ${FONT}; font-size: 12.5px; font-weight: 600; color: ${DP.ink};4002transition: background 0.12s ease;4003}4004.coll-head:hover { background: ${DP.tileAlt}; }4005.coll-chev {4006width: 12px; height: 12px; flex-shrink: 0;4007color: ${DP.meta};4008transition: transform 0.2s ${EASE};4009}4010.coll[data-open="true"] .coll-chev { transform: rotate(90deg); }4011.coll-count { margin-left: auto; font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; }4012.coll-body { padding: 0 14px 14px; display: none; }4013.coll[data-open="true"] .coll-body { display: block; }40144015.rule-card {4016padding: 10px 0;4017border-top: 1px solid ${DP.hairlineSoft};4018}4019.rule-card:first-child { border-top: none; padding-top: 2px; }4020.rule-card .name { font-size: 11.5px; font-weight: 700; color: ${DP.ink}; margin-bottom: 3px; }4021.rule-card .name .section { font-family: ${MONO}; font-size: 9px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; margin-left: 8px; }4022.rule-card .body { font-size: 11.5px; color: ${DP.ink2}; line-height: 1.5; }40234024.coll .dos { display: grid; gap: 0; margin-top: 2px; }4025.coll .do, .coll .dont {4026position: relative;4027padding: 8px 0 8px 22px;4028font-size: 11.5px; line-height: 1.5; color: ${DP.ink2};4029border-top: 1px solid ${DP.hairlineSoft};4030}4031.coll .do:first-child, .coll .dont:first-child,4032.coll .do:first-of-type { border-top: none; }4033.coll .do + .dont { border-top: 1px solid ${DP.hairlineSoft}; }4034.coll .do::before, .coll .dont::before {4035content: ''; position: absolute; left: 4px; top: 13px;4036width: 8px; height: 8px; border-radius: 50%;4037}4038.coll .do::before { background: oklch(62% 0.16 145); }4039.coll .dont::before { background: oklch(58% 0.22 25); }40404041.coll .overview-body {4042font-size: 12px; line-height: 1.55; color: ${DP.ink2};4043}4044.coll .overview-body .north-star {4045display: block; font-family: ${FONT}; font-style: italic;4046font-size: 15px; line-height: 1.3; color: ${DP.ink};4047margin-bottom: 8px;4048}4049.coll .overview-body p { margin: 0 0 8px; }4050.coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; }4051.coll .overview-body li { margin-bottom: 3px; }40524053/* --- raw tab markdown (unchanged layout, neutralized palette) --- */4054.md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; }4055.md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; }4056.md h1 { font-size: 18px; }4057.md h2 { font-size: 15px; padding-bottom: 4px; border-bottom: 1px solid ${DP.hairlineSoft}; }4058.md h3 { font-size: 13px; }4059.md h4 { font-size: 12px; color: ${DP.meta}; }4060.md p { margin: 0 0 10px; }4061.md ul, .md ol { margin: 0 0 10px; padding-left: 20px; }4062.md li { margin-bottom: 4px; }4063.md code { font-family: ${MONO}; font-size: 12px; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; }4064.md pre { font-family: ${MONO}; font-size: 12px; background: ${DP.canvas}; padding: 10px 12px; border-radius: 8px; overflow-x: auto; margin: 0 0 10px; }4065.md pre code { background: none; padding: 0; }4066.md strong { font-weight: 700; }4067.md em { font-style: italic; }4068.md a { color: ${DP.ink}; text-decoration: underline; }4069.md hr { border: none; border-top: 1px solid ${DP.hairlineSoft}; margin: 16px 0; }4070`;4071}40724073function renderDesignChrome() {4074const root = designShadow.querySelector('.root');4075root.innerHTML = '';40764077// (Panel toggle lives in the global bar — no floating FAB.)4078// Panel4079const panel = document.createElement('aside');4080panel.className = 'panel';4081panel.setAttribute('data-open', designState.open ? 'true' : 'false');4082panel.appendChild(buildDesignHeader());4083const body = document.createElement('div');4084body.className = 'panel-body';4085body.id = 'panel-body';4086panel.appendChild(body);4087root.appendChild(panel);40884089renderDesignBody();4090}40914092function buildDesignHeader() {4093const header = document.createElement('div');4094header.className = 'panel-header';40954096const title = document.createElement('div');4097title.className = 'panel-title';4098title.textContent = 'DESIGN.md';4099header.appendChild(title);41004101const tabs = document.createElement('div');4102tabs.className = 'tabs';4103for (const t of [['visual', 'Visual'], ['raw', 'Raw']]) {4104const btn = document.createElement('button');4105btn.className = 'tab';4106btn.textContent = t[1];4107btn.setAttribute('data-active', designState.tab === t[0] ? 'true' : 'false');4108btn.addEventListener('click', () => {4109if (designState.tab === t[0]) return;4110designState.tab = t[0];4111saveDesignPrefs();4112renderDesignChrome();4113if (t[0] === 'raw' && designState.raw === null && !designState.loading) {4114fetchDesignSystem(); // raw is part of the same fetch pair4115}4116});4117tabs.appendChild(btn);4118}4119header.appendChild(tabs);41204121const close = document.createElement('button');4122close.className = 'panel-close';4123close.innerHTML = '✕';4124close.setAttribute('aria-label', 'Close panel');4125close.addEventListener('click', toggleDesignPanel);4126header.appendChild(close);41274128return header;4129}41304131function toggleDesignPanel() {4132designState.open = !designState.open;4133renderDesignChrome();4134updateGlobalBarState();4135if (designState.open && designState.present === null && !designState.loading) {4136fetchDesignSystem();4137}4138}41394140async function fetchDesignSystem() {4141designState.loading = true;4142designState.error = null;4143renderDesignBody();4144try {4145const [jsonRes, rawRes] = await Promise.all([4146fetch(`http://localhost:${PORT}/design-system.json?token=${TOKEN}`, { cache: 'no-store' }),4147fetch(`http://localhost:${PORT}/design-system/raw?token=${TOKEN}`, { cache: 'no-store' }),4148]);4149const jsonData = await jsonRes.json();4150designState.present = jsonData.present === true;4151designState.parsed = jsonData.parsed || null;4152designState.sidecar = jsonData.sidecar || null;4153designState.hasMd = !!jsonData.hasMd;4154designState.hasSidecar = !!jsonData.hasSidecar;4155designState.mdNewerThanJson = !!jsonData.mdNewerThanJson;4156designState.raw = designState.present && rawRes.ok ? await rawRes.text() : null;4157designState.error = jsonData.parseError || jsonData.sidecarError || null;4158} catch (err) {4159designState.error = err?.message || 'Failed to load design system.';4160} finally {4161designState.loading = false;4162renderDesignChrome(); // refresh title from data4163}4164}41654166function renderDesignBody() {4167const body = designShadow.querySelector('#panel-body');4168if (!body) return;4169body.innerHTML = '';41704171if (designState.loading) {4172body.appendChild(msgDiv('loading', 'Loading design system…'));4173return;4174}4175if (designState.error) {4176body.appendChild(msgDiv('error', designState.error));4177return;4178}4179if (designState.present === false) {4180const empty = document.createElement('div');4181empty.className = 'empty';4182empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>/impeccable document</code> in your terminal, then re-open this panel.`;4183body.appendChild(empty);4184return;4185}41864187if (designState.tab === 'raw') {4188renderRawTab(body, designState.raw || '');4189return;4190}41914192// Visual tab — single unified render path.4193if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());4194if (designState.hasMd && !designState.hasSidecar) {4195body.appendChild(renderParsedMdCta());4196}4197renderDesignVisual(body, designState.parsed, designState.sidecar);4198}41994200function msgDiv(cls, text) {4201const d = document.createElement('div');4202d.className = cls;4203d.textContent = text;4204return d;4205}42064207function renderStaleHint() {4208const box = document.createElement('div');4209box.className = 'stale';4210box.innerHTML = `4211<span class="stale-dot"></span>4212<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>4213`;4214return box;4215}42164217function renderParsedMdCta() {4218const box = document.createElement('div');4219box.className = 'parsed-md-cta';4220box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;4221return box;4222}42234224// --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 ---42254226function renderDesignVisual(body, parsed, sidecar) {4227const frontmatter = parsed?.frontmatter || {};4228const extensions = sidecar?.extensions || {};4229const proseColors = parsed?.colors || null;42304231const colors = buildColorModels(frontmatter.colors, extensions.colorMeta, proseColors);4232if (colors.length) renderColorTiles(body, colors);42334234const types = buildTypographyModels(frontmatter.typography, extensions.typographyMeta);4235if (types.length) renderTypeTiles(body, types);42364237const radii = buildRadiiModels(frontmatter.rounded);4238if (radii.length) renderRadiiTile(body, radii);42394240if (extensions.shadows?.length) renderShadowTiles(body, extensions.shadows);42414242const components = sidecar?.components || [];4243if (components.length) renderComponentTiles(body, components);42444245// Narrative: sidecar wins if present (richer, agent-curated). Otherwise4246// synthesize from prose sections.4247const narrative = sidecar?.narrative || synthesizeNarrative(parsed);4248if (narrative.rules?.length) body.appendChild(renderRulesCollapsible(narrative.rules));4249if ((narrative.dos?.length || narrative.donts?.length)) body.appendChild(renderDosDontsCollapsible(narrative));4250if (narrative.overview || narrative.northStar || narrative.keyCharacteristics?.length) {4251body.appendChild(renderOverviewCollapsible(narrative));4252}42534254if (body.childElementCount === 0) {4255body.appendChild(msgDiv('empty', 'No design system data available.'));4256}4257}42584259// Frontmatter primitives + sidecar colorMeta → tile-ready color models.4260// A matching prose bullet (when the slug sits in the bullet text) supplies4261// description as a last-resort fallback.4262function buildColorModels(fmColors, colorMeta, proseColors) {4263if (!fmColors) return [];4264const meta = colorMeta || {};4265return Object.entries(fmColors).map(([key, value]) => {4266const m = meta[key] || {};4267return {4268role: m.role || humanizeKey(key),4269name: m.displayName || humanizeKey(key),4270value: value,4271canonical: m.canonical || null,4272description: m.description || findProseDescription(proseColors, key, m.displayName),4273tonalRamp: m.tonalRamp || null,4274};4275});4276}42774278function buildTypographyModels(fmTypography, typographyMeta) {4279if (!fmTypography) return [];4280const meta = typographyMeta || {};4281return Object.entries(fmTypography).map(([key, spec]) => {4282const m = meta[key] || {};4283const { family, fallback } = splitFontFamily(spec?.fontFamily);4284return {4285role: key,4286name: m.displayName || humanizeKey(key),4287family,4288fallback,4289weight: spec?.fontWeight ?? 400,4290// fontStyle isn't in Stitch's frontmatter schema; the sidecar carries4291// it when a role is rendered in italic (e.g. display italic).4292style: m.style || 'normal',4293sampleSize: spec?.fontSize || '1rem',4294lineHeight: spec?.lineHeight != null ? String(spec.lineHeight) : '',4295letterSpacing: spec?.letterSpacing,4296purpose: m.purpose,4297};4298});4299}43004301function buildRadiiModels(fmRounded) {4302if (!fmRounded) return [];4303return Object.entries(fmRounded).map(([name, value]) => ({ name, value }));4304}43054306function splitFontFamily(stack) {4307if (!stack || typeof stack !== 'string') return { family: '', fallback: '' };4308const parts = stack.split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, ''));4309return { family: parts[0] || '', fallback: parts.slice(1).join(', ') };4310}43114312function humanizeKey(k) {4313return String(k || '').replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());4314}43154316function findProseDescription(proseColors, key, displayName) {4317if (!proseColors || !proseColors.groups) return null;4318const needles = [key, displayName].filter(Boolean).map((s) => s.toLowerCase());4319for (const g of proseColors.groups) {4320for (const c of g.colors || []) {4321const hay = String(c.name || '').toLowerCase();4322if (hay && needles.some((n) => hay.includes(n) || n.includes(hay))) {4323