1/**
2 * Impeccable Live Variant Mode — Browser Script
3 *
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 cycling
10 * (prev/next + accept/discard). Feels like Spotlight, not a modal.
11 */
12(function () {
13 'use strict';
14 if (typeof window === 'undefined') return;
15
16 // Guard against double-init. Bun's HTML loader may process the <script> tag
17 // and create a bundled copy alongside the external load, or HMR may re-execute.
18 // Check BEFORE reading token/port to catch all cases.
19 if (window.__IMPECCABLE_LIVE_INIT__) return;
20 window.__IMPECCABLE_LIVE_INIT__ = true;
21
22 const TOKEN = window.__IMPECCABLE_TOKEN__;
23 const PORT = window.__IMPECCABLE_PORT__;
24 if (!TOKEN || !PORT) {
25 window.__IMPECCABLE_LIVE_INIT__ = false; // reset so the real load can init
26 return;
27 }
28
29 // ---------------------------------------------------------------------------
30 // Design tokens
31 // ---------------------------------------------------------------------------
32
33 // 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 washed
35 // theme-adjusted one.
36 const C = {
37 brand: 'oklch(60% 0.25 350)',
38 brandHov: 'oklch(52% 0.25 350)',
39 brandSoft: 'oklch(60% 0.25 350 / 0.15)',
40 ink: 'oklch(15% 0.01 350)',
41 ash: 'oklch(55% 0 0)',
42 paper: 'oklch(98% 0.005 350 / 0.92)',
43 paperSolid:'oklch(98% 0.005 350)',
44 mist: 'oklch(90% 0.01 350 / 0.6)',
45 white: 'oklch(99% 0 0)',
46 };
47 const FONT = 'system-ui, -apple-system, sans-serif';
48 const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';
49 // z-index: detect overlays use 99999, so our UI must be above them
50 const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
51 const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
52 const PREFIX = 'impeccable-live';
53 const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
54 prefix: PREFIX,
55 storage: localStorage,
56 idFactory: () => crypto.randomUUID().replace(/-/g, '').slice(0, 8),
57 });
58 if (!sessionState) {
59 console.error('[impeccable] live-browser-session.js was not loaded. Live mode cannot start safely.');
60 window.__IMPECCABLE_LIVE_INIT__ = false;
61 return;
62 }
63 const HIGHLIGHT_TRANSITION =
64 'top 140ms ' + EASE +
65 ', left 140ms ' + EASE +
66 ', width 140ms ' + EASE +
67 ', height 140ms ' + EASE +
68 ', opacity 150ms ease';
69 const TOOLTIP_TRANSITION =
70 'top 140ms ' + EASE + ', left 140ms ' + EASE + ', opacity 150ms ease';
71
72 const SKIP_TAGS = new Set([
73 'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
74 ]);
75
76 // SVG icons stack above each chip label. All strokes use currentColor so the
77 // 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.
79 const 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"';
80 const ICONS = {
81 impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
82 bolder: `<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>`,
83 quieter: `<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>`,
84 distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,
85 polish: `<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>`,
86 typeset: `<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>`,
87 colorize: `<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>`,
88 layout: `<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>`,
89 adapt: `<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>`,
90 animate: `<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>`,
91 delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,
92 overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,
93 };
94
95 const 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 ];
109
110 // ---------------------------------------------------------------------------
111 // State
112 // ---------------------------------------------------------------------------
113
114 let state = 'IDLE';
115 let hoveredElement = null;
116 let selectedElement = null;
117 let currentSessionId = null;
118 let expectedVariants = 0;
119 let arrivedVariants = 0;
120 let visibleVariant = 0;
121 let variantObserver = null;
122 let hasProjectContext = false;
123 let selectedAction = 'impeccable';
124 let selectedCount = 3;
125 const browserOwner = sessionState.owner;
126 let checkpointTimer = null;
127
128 // Scroll lock — holds window.scrollY at a fixed value while the session is
129 // active, so HMR DOM patches and variant swaps can't drift the page. See
130 // startScrollLock / stopScrollLock below.
131 let scrollLockObserver = null;
132 let scrollLockTargetY = null;
133 let scrollLockRaf = null;
134 let scrollLockAbort = null;
135
136 // Dedicated key for scroll position — SEPARATE from LS_KEY so that
137 // saveSession's state updates don't clobber a carefully-captured scrollY.
138 // (Previously: saveSession wrote scrollY alongside state, so every call
139 // during resume overwrote the pre-reload value with whatever the browser
140 // had landed on, typically 0.)
141 function writeScrollY(y) { sessionState.writeScrollY(y); }
142 function readScrollY() { return sessionState.readScrollY(); }
143 function clearScrollY() { sessionState.clearScrollY(); }
144
145 // Pre-empt the browser: apply manual scroll restoration and jump to the
146 // saved scrollY at script-parse time. Retries on fonts.ready and load
147 // are essential: scrollTo(y) clamps to the current document.scrollHeight,
148 // which is often hundreds of pixels short of the final value until
149 // async-loaded fonts swap in and reflow.
150 try {
151 history.scrollRestoration = 'manual';
152 const savedY = readScrollY();
153 if (savedY != null) {
154 const apply = () => {
155 if (Math.abs(window.scrollY - savedY) > 0.5) {
156 console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY });
157 window.scrollTo(0, savedY);
158 }
159 };
160 apply();
161 if (document.fonts?.ready) document.fonts.ready.then(apply).catch(() => {});
162 window.addEventListener('load', apply, { once: true });
163 }
164 } catch {}
165
166 // UI refs
167 let highlightEl = null;
168 let tooltipEl = null;
169 let barEl = null;
170 let pickerEl = null;
171 let toastEl = null;
172 let scrollRaf = null;
173
174 // ---------------------------------------------------------------------------
175 // Helpers
176 // ---------------------------------------------------------------------------
177
178 function own(el) {
179 return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]'));
180 }
181
182 function pickable(el) {
183 if (!el || el.nodeType !== 1) return false;
184 if (SKIP_TAGS.has(el.tagName.toLowerCase())) return false;
185 if (own(el)) return false;
186 const r = el.getBoundingClientRect();
187 return r.width >= 20 && r.height >= 20;
188 }
189
190 function desc(el) {
191 if (!el) return '';
192 let s = el.tagName.toLowerCase();
193 if (el.id) s += '#' + el.id;
194 else if (el.classList.length) s += '.' + [...el.classList].slice(0, 2).join('.');
195 return s;
196 }
197
198 function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
199
200 // 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 a
204 // modal dialog:
205 //
206 // 1. Modal scroll-lock disables outside pointer events. Radix's
207 // `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
208 // while a modal is open and only restores `auto` on the layer. Our
209 // chrome inherits `none` from <body> and becomes unclickable.
210 // 2. The dialog's outside-interaction handler (Radix's
211 // `usePointerDownOutside`) listens at document level and dismisses
212 // the dialog whenever a `pointerdown` lands outside the layer node.
213 // Our chrome is a sibling of <body>, so Radix classifies our clicks
214 // as outside and tears the dialog down mid-task.
215 //
216 // We can't reliably re-parent our chrome into the dialog subtree (z-index
217 // stacking, scroll containers, theming all become host-page concerns), so
218 // 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-level
222 // dismiss listener never fires for our clicks.
223 // - Stop `focusin` propagation so any focus shifts inside our chrome
224 // don't read as "focus moved outside the dialog" to focus traps.
225 //
226 // Click events still bubble normally — only the early pointer/focus
227 // signals that drive outside-interaction detection are silenced.
228 function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
229 if (!rootEl) return;
230 if (setPointerEvents) {
231 rootEl.style.setProperty('pointer-events', 'auto', 'important');
232 }
233 const stop = (e) => e.stopPropagation();
234 rootEl.addEventListener('pointerdown', stop);
235 rootEl.addEventListener('mousedown', stop);
236 rootEl.addEventListener('focusin', stop);
237 }
238
239 // ---------------------------------------------------------------------------
240 // Highlight overlay
241 // ---------------------------------------------------------------------------
242
243 function initHighlight() {
244 highlightEl = document.createElement('div');
245 highlightEl.id = PREFIX + '-highlight';
246 Object.assign(highlightEl.style, {
247 position: 'fixed', top: '0', left: '0', width: '0', height: '0',
248 border: '2px solid ' + C.brand, borderRadius: '3px',
249 pointerEvents: 'none', zIndex: Z.highlight, boxSizing: 'border-box',
250 transition: HIGHLIGHT_TRANSITION,
251 display: 'none', opacity: '0',
252 });
253 document.body.appendChild(highlightEl);
254
255 tooltipEl = document.createElement('div');
256 tooltipEl.id = PREFIX + '-tooltip';
257 Object.assign(tooltipEl.style, {
258 position: 'fixed',
259 background: C.ink, color: C.white,
260 fontFamily: MONO, fontSize: '10px', fontWeight: '500',
261 padding: '2px 6px', borderRadius: '3px',
262 zIndex: Z.highlight + 1, pointerEvents: 'none',
263 whiteSpace: 'nowrap', display: 'none',
264 letterSpacing: '0.02em',
265 transition: TOOLTIP_TRANSITION,
266 });
267 document.body.appendChild(tooltipEl);
268 }
269
270 function showHighlight(el) {
271 if (!el || !highlightEl) return;
272 const r = el.getBoundingClientRect();
273 const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px';
274 const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px';
275 const tipTop = r.top - 20;
276 const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px';
277 const tipX = Math.max(4, r.left) + 'px';
278 tooltipEl.textContent = desc(el);
279
280 const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0';
281 if (hiWasHidden) {
282 // Snap to first target without animating from (0,0), then fade in.
283 highlightEl.style.transition = 'none';
284 Object.assign(highlightEl.style, { top, left, width, height, display: 'block' });
285 tooltipEl.style.transition = 'none';
286 Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' });
287 void highlightEl.offsetWidth;
288 highlightEl.style.transition = HIGHLIGHT_TRANSITION;
289 highlightEl.style.opacity = '1';
290 tooltipEl.style.transition = TOOLTIP_TRANSITION;
291 tooltipEl.style.opacity = '1';
292 } else {
293 Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' });
294 Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' });
295 }
296 }
297
298 function hideHighlight() {
299 if (highlightEl) { highlightEl.style.opacity = '0'; highlightEl.style.display = 'none'; }
300 if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; }
301 }
302
303 // ---------------------------------------------------------------------------
304 // Annotation overlay (comment pins + magenta strokes)
305 //
306 // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned
307 // sibling of <body> mirroring selectedElement's bounding rect. Click (no
308 // drag) drops a comment pin; drag paints a magenta SVG stroke. All coords
309 // are stored in element-local CSS px so they survive scroll / resize and
310 // correlate directly with the captured PNG.
311 // ---------------------------------------------------------------------------
312
313 const DRAG_THRESHOLD = 5; // px — below this, treat pointerup as a click
314 const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
315 let annotOverlayEl = null;
316 let annotSvgEl = null;
317 let annotPinsEl = null;
318 let annotClearChipEl = null;
319 let annotState = { comments: [], strokes: [] };
320 let annotActive = false;
321 // `annotPointer` is either:
322 // { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin
323 // { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin
324 let annotPointer = null;
325 let annotEditing = null; // { idx, input, wrapEl }
326 let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete
327
328 function initAnnotOverlay() {
329 annotOverlayEl = document.createElement('div');
330 annotOverlayEl.id = PREFIX + '-annot';
331 Object.assign(annotOverlayEl.style, {
332 position: 'fixed', top: '0', left: '0', width: '0', height: '0',
333 pointerEvents: 'auto', zIndex: Z.highlight + 2,
334 display: 'none', overflow: 'visible',
335 cursor: 'crosshair', touchAction: 'none',
336 });
337
338 annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
339 annotSvgEl.id = PREFIX + '-annot-svg';
340 Object.assign(annotSvgEl.style, {
341 position: 'absolute', top: '0', left: '0',
342 width: '100%', height: '100%',
343 // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via
344 // pointer-events=stroke so gaps still fall through to the overlay.
345 pointerEvents: 'none', overflow: 'visible',
346 });
347 annotOverlayEl.appendChild(annotSvgEl);
348
349 annotPinsEl = document.createElement('div');
350 annotPinsEl.id = PREFIX + '-annot-pins';
351 Object.assign(annotPinsEl.style, {
352 position: 'absolute', inset: '0',
353 pointerEvents: 'none',
354 });
355 annotOverlayEl.appendChild(annotPinsEl);
356
357 annotClearChipEl = document.createElement('div');
358 annotClearChipEl.id = PREFIX + '-annot-clear';
359 annotClearChipEl.dataset.annotClear = 'true';
360 annotClearChipEl.textContent = 'Clear';
361 Object.assign(annotClearChipEl.style, {
362 position: 'absolute', top: '8px', right: '8px',
363 background: C.ink, color: C.white,
364 fontFamily: FONT, fontSize: '10px', fontWeight: '500',
365 letterSpacing: '0.08em', textTransform: 'uppercase',
366 padding: '5px 12px', borderRadius: '999px',
367 cursor: 'pointer', pointerEvents: 'auto',
368 display: 'none', userSelect: 'none',
369 boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
370 });
371 annotOverlayEl.appendChild(annotClearChipEl);
372
373 annotOverlayEl.addEventListener('pointerdown', onAnnotDown);
374 annotOverlayEl.addEventListener('pointermove', onAnnotMove);
375 annotOverlayEl.addEventListener('pointerup', onAnnotUp);
376 annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
377 document.body.appendChild(annotOverlayEl);
378 // Modal-host friendliness: pointer-events is already 'auto' on this
379 // overlay; we only need to silence the host's outside-interaction
380 // listeners. Don't override pointer-events here (the overlay toggles
381 // visibility via display:none, which is fine).
382 defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
383 }
384
385 function updateClearChip() {
386 if (!annotClearChipEl) return;
387 const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0;
388 annotClearChipEl.style.display = hasAny ? 'block' : 'none';
389 }
390
391 function showAnnotOverlay(el) {
392 if (!annotOverlayEl || !el) return;
393 annotActive = true;
394 positionAnnotOverlay(el);
395 annotOverlayEl.style.display = 'block';
396 }
397
398 function hideAnnotOverlay() {
399 annotActive = false;
400 if (annotOverlayEl) annotOverlayEl.style.display = 'none';
401 // Drop any in-progress edit without touching annotState — clearAnnotations
402 // (if the caller is exiting configure mode) handles state reset.
403 annotEditing = null;
404 }
405
406 function positionAnnotOverlay(el) {
407 if (!annotOverlayEl || !el) return;
408 const r = el.getBoundingClientRect();
409 Object.assign(annotOverlayEl.style, {
410 top: r.top + 'px', left: r.left + 'px',
411 width: r.width + 'px', height: r.height + 'px',
412 });
413 annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height);
414 }
415
416 function clearAnnotations() {
417 annotState.comments = [];
418 annotState.strokes = [];
419 if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild);
420 if (annotPinsEl) annotPinsEl.innerHTML = '';
421 annotPointer = null;
422 annotEditing = null;
423 annotLastPinClick = { idx: -1, time: 0 };
424 updateClearChip();
425 }
426
427 // Rebuild the SVG layer. Each stroke gets a wider invisible hit path
428 // beneath the visible magenta path so clicks register on thin lines.
429 function redrawStrokes() {
430 while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild);
431 annotState.strokes.forEach((s, idx) => {
432 const d = pointsToPath(s.points);
433 const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path');
434 hit.setAttribute('d', d);
435 hit.setAttribute('stroke', 'transparent');
436 hit.setAttribute('stroke-width', '16');
437 hit.setAttribute('stroke-linecap', 'round');
438 hit.setAttribute('stroke-linejoin', 'round');
439 hit.setAttribute('fill', 'none');
440 hit.setAttribute('pointer-events', 'stroke');
441 hit.style.cursor = 'pointer';
442 hit.dataset.annotStroke = String(idx);
443 annotSvgEl.appendChild(hit);
444 const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path');
445 visible.setAttribute('d', d);
446 visible.setAttribute('stroke', C.brand);
447 visible.setAttribute('stroke-width', '3');
448 visible.setAttribute('stroke-linecap', 'round');
449 visible.setAttribute('stroke-linejoin', 'round');
450 visible.setAttribute('fill', 'none');
451 visible.setAttribute('pointer-events', 'none');
452 annotSvgEl.appendChild(visible);
453 });
454 updateClearChip();
455 }
456
457 function localCoords(e) {
458 const rect = annotOverlayEl.getBoundingClientRect();
459 return { x: e.clientX - rect.left, y: e.clientY - rect.top };
460 }
461
462 function onAnnotDown(e) {
463 if (!annotActive) return;
464
465 // 1) Clear chip → wipe all annotations
466 if (e.target.closest?.('[data-annot-clear]')) {
467 if (annotEditing) annotEditing = null;
468 clearAnnotations();
469 renderAllPins();
470 redrawStrokes();
471 e.stopPropagation(); e.preventDefault();
472 return;
473 }
474
475 // 2) Stroke hit path → delete that stroke
476 const strokeHit = e.target.closest?.('[data-annot-stroke]');
477 if (strokeHit) {
478 const idx = parseInt(strokeHit.dataset.annotStroke, 10);
479 if (Number.isInteger(idx)) {
480 annotState.strokes.splice(idx, 1);
481 redrawStrokes();
482 }
483 e.stopPropagation(); e.preventDefault();
484 return;
485 }
486
487 // 3) Pin → drag, edit, or delete-on-double-click
488 const pinWrap = e.target.closest?.('[data-annot-pin]');
489 if (pinWrap) {
490 const idx = parseInt(pinWrap.dataset.annotPin, 10);
491 if (!Number.isInteger(idx)) return;
492 // Double-click (two pointerdowns on the same pin within window) → delete.
493 const now = Date.now();
494 if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) {
495 if (annotEditing && annotEditing.idx === idx) annotEditing = null;
496 annotState.comments.splice(idx, 1);
497 annotLastPinClick = { idx: -1, time: 0 };
498 renderAllPins();
499 e.stopPropagation(); e.preventDefault();
500 return;
501 }
502 annotLastPinClick = { idx, time: now };
503 // If editing a different pin, commit that edit before starting here.
504 if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
505 // If already editing THIS pin and the user clicked the dot, let the
506 // input keep focus (don't start a drag — the click wasn't meant as one).
507 if (annotEditing && annotEditing.idx === idx) return;
508 const p = localCoords(e);
509 const pin = annotState.comments[idx];
510 annotPointer = {
511 kind: 'pin', idx,
512 startPointer: p,
513 startPin: { x: pin.x, y: pin.y },
514 moved: false,
515 };
516 try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {}
517 e.stopPropagation(); e.preventDefault();
518 return;
519 }
520
521 // 4) Empty area → commit any open edit, then start new annotation
522 if (annotEditing) {
523 finalizeEditingPin();
524 e.stopPropagation(); e.preventDefault();
525 return;
526 }
527 const p = localCoords(e);
528 annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null };
529 try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {}
530 e.stopPropagation(); e.preventDefault();
531 }
532
533 function onAnnotMove(e) {
534 if (!annotActive || !annotPointer) return;
535 const p = localCoords(e);
536
537 if (annotPointer.kind === 'pin') {
538 const dx = p.x - annotPointer.startPointer.x;
539 const dy = p.y - annotPointer.startPointer.y;
540 if (!annotPointer.moved) {
541 if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
542 annotPointer.moved = true;
543 }
544 const pin = annotState.comments[annotPointer.idx];
545 if (!pin) { annotPointer = null; return; }
546 pin.x = annotPointer.startPin.x + dx;
547 pin.y = annotPointer.startPin.y + dy;
548 renderAllPins();
549 e.stopPropagation();
550 return;
551 }
552
553 // kind === 'new'
554 const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0;
555 if (!annotPointer.moved) {
556 if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
557 annotPointer.moved = true;
558 const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
559 strokeEl.setAttribute('stroke', C.brand);
560 strokeEl.setAttribute('stroke-width', '3');
561 strokeEl.setAttribute('stroke-linecap', 'round');
562 strokeEl.setAttribute('stroke-linejoin', 'round');
563 strokeEl.setAttribute('fill', 'none');
564 strokeEl.setAttribute('pointer-events', 'none');
565 annotSvgEl.appendChild(strokeEl);
566 annotPointer.strokeEl = strokeEl;
567 annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]];
568 }
569 annotPointer.strokePoints.push([p.x, p.y]);
570 annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints));
571 e.stopPropagation();
572 }
573
574 function onAnnotUp(e) {
575 if (!annotActive || !annotPointer) return;
576
577 if (annotPointer.kind === 'pin') {
578 const wasDrag = annotPointer.moved;
579 const idx = annotPointer.idx;
580 try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {}
581 annotPointer = null;
582 if (wasDrag) {
583 // A drag is an intentional reposition; a follow-up click shouldn't be
584 // interpreted as a double-click-to-delete.
585 annotLastPinClick = { idx: -1, time: 0 };
586 } else {
587 beginEditPin(idx);
588 }
589 e.stopPropagation();
590 return;
591 }
592
593 // kind === 'new'
594 const wasDrag = annotPointer.moved;
595 if (wasDrag) {
596 annotState.strokes.push({ points: annotPointer.strokePoints });
597 // Swap the temporary preview SVG path for the full render with hit paths.
598 redrawStrokes();
599 } else {
600 const idx = annotState.comments.length;
601 annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' });
602 renderAllPins();
603 beginEditPin(idx);
604 }
605 try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {}
606 annotPointer = null;
607 e.stopPropagation();
608 }
609
610 function pointsToPath(points) {
611 if (!points || points.length === 0) return '';
612 let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1);
613 for (let i = 1; i < points.length; i++) {
614 d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1);
615 }
616 return d;
617 }
618
619 function renderAllPins() {
620 annotPinsEl.innerHTML = '';
621 annotState.comments.forEach((c, idx) => {
622 annotPinsEl.appendChild(buildPinElement(c, idx));
623 });
624 updateClearChip();
625 }
626
627 function buildPinElement(comment, idx) {
628 const interactive = idx >= 0;
629 const wrap = document.createElement('div');
630 if (interactive) wrap.dataset.annotPin = String(idx);
631 Object.assign(wrap.style, {
632 position: 'absolute',
633 left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px',
634 pointerEvents: interactive ? 'auto' : 'none',
635 display: 'flex', alignItems: 'flex-start', gap: '6px',
636 cursor: interactive ? 'grab' : 'default',
637 touchAction: 'none',
638 });
639 const dot = document.createElement('div');
640 Object.assign(dot.style, {
641 width: '14px', height: '14px', borderRadius: '50%',
642 background: C.brand, border: '2px solid ' + C.white,
643 boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
644 flexShrink: '0',
645 });
646 wrap.appendChild(dot);
647
648 if (comment.text) {
649 const bubble = document.createElement('div');
650 bubble.textContent = comment.text;
651 Object.assign(bubble.style, {
652 background: C.ink, color: C.white,
653 fontFamily: FONT, fontSize: '12px', lineHeight: '1.4',
654 padding: '4px 8px', borderRadius: '3px',
655 marginTop: '-2px', maxWidth: '220px',
656 pointerEvents: 'none', whiteSpace: 'pre-wrap',
657 wordBreak: 'break-word',
658 });
659 wrap.appendChild(bubble);
660 }
661 return wrap;
662 }
663
664 function beginEditPin(idx) {
665 const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]');
666 if (!wrapEl) return;
667 // Strip any existing bubble (but keep the dot)
668 wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove());
669 const input = document.createElement('input');
670 input.type = 'text';
671 input.placeholder = 'Note…';
672 Object.assign(input.style, {
673 background: C.ink, color: C.white,
674 fontFamily: FONT, fontSize: '12px', lineHeight: '1.4',
675 padding: '4px 8px', borderRadius: '3px',
676 border: '1px solid ' + C.brand,
677 outline: 'none', marginTop: '-2px',
678 width: '220px', pointerEvents: 'auto',
679 });
680 const originalText = annotState.comments[idx].text || '';
681 input.value = originalText;
682 wrapEl.appendChild(input);
683 annotEditing = { idx, input, wrapEl, originalText };
684 input.addEventListener('keydown', onAnnotInputKey, true);
685 input.addEventListener('blur', () => {
686 // Fires on both focus-loss and programmatic blur; commit unless we
687 // already handled it.
688 if (annotEditing && annotEditing.input === input) finalizeEditingPin();
689 });
690 // Stop clicks/pointerdowns inside the input from bubbling to the overlay
691 ['pointerdown', 'click'].forEach(ev => {
692 input.addEventListener(ev, e => e.stopPropagation());
693 });
694 setTimeout(() => input.focus(), 0);
695 }
696
697 function onAnnotInputKey(e) {
698 if (e.key === 'Enter') {
699 e.preventDefault(); e.stopPropagation();
700 finalizeEditingPin();
701 } else if (e.key === 'Escape') {
702 e.preventDefault(); e.stopPropagation();
703 cancelEditingPin();
704 } else {
705 // Keep arrows / backspace from hitting global handlers
706 e.stopPropagation();
707 }
708 }
709
710 function finalizeEditingPin() {
711 if (!annotEditing) return;
712 const { idx, input } = annotEditing;
713 const text = input.value.trim();
714 annotEditing = null;
715 if (text) annotState.comments[idx].text = text;
716 else annotState.comments.splice(idx, 1);
717 renderAllPins();
718 }
719
720 function cancelEditingPin() {
721 if (!annotEditing) return;
722 const { idx, originalText } = annotEditing;
723 annotEditing = null;
724 // If the pin had text before this edit, revert to it. If it was a
725 // just-created empty pin, Escape removes it.
726 if (originalText) {
727 annotState.comments[idx].text = originalText;
728 } else {
729 annotState.comments.splice(idx, 1);
730 }
731 renderAllPins();
732 }
733
734 // Build a detached annotation subtree suitable for injection into the clone
735 // modern-screenshot creates. Coordinates are element-local so this slots
736 // straight into an element that's been made position:relative. Takes an
737 // explicit snapshot so it works after annotState has been cleared.
738 function buildAnnotationsForCapture(rect, snapshot) {
739 const comments = snapshot ? snapshot.comments : annotState.comments;
740 const strokes = snapshot ? snapshot.strokes : annotState.strokes;
741 if (comments.length === 0 && strokes.length === 0) return null;
742 const wrap = document.createElement('div');
743 Object.assign(wrap.style, {
744 position: 'absolute', top: '0', left: '0',
745 width: rect.width + 'px', height: rect.height + 'px',
746 pointerEvents: 'none', overflow: 'visible',
747 });
748 if (strokes.length > 0) {
749 const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
750 svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height);
751 Object.assign(svg.style, {
752 position: 'absolute', top: '0', left: '0',
753 width: '100%', height: '100%', overflow: 'visible',
754 });
755 for (const s of strokes) {
756 const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
757 path.setAttribute('stroke', C.brand);
758 path.setAttribute('stroke-width', '3');
759 path.setAttribute('stroke-linecap', 'round');
760 path.setAttribute('stroke-linejoin', 'round');
761 path.setAttribute('fill', 'none');
762 path.setAttribute('d', pointsToPath(s.points));
763 svg.appendChild(path);
764 }
765 wrap.appendChild(svg);
766 }
767 for (const c of comments) {
768 // idx=-1 means non-interactive; pointerEvents stay off in the clone
769 wrap.appendChild(buildPinElement(c, -1));
770 }
771 return wrap;
772 }
773
774 // ---------------------------------------------------------------------------
775 // Element context extraction
776 // ---------------------------------------------------------------------------
777
778 function extractContext(el) {
779 const cs = getComputedStyle(el);
780 const r = el.getBoundingClientRect();
781 const props = {};
782 for (const sheet of document.styleSheets) {
783 try {
784 for (const rule of sheet.cssRules) {
785 if (rule.style) for (let i = 0; i < rule.style.length; i++) {
786 const p = rule.style[i];
787 if (p.startsWith('--') && !props[p]) {
788 const v = cs.getPropertyValue(p).trim();
789 if (v) props[p] = v;
790 }
791 }
792 }
793 } catch { /* cross-origin */ }
794 }
795 return {
796 tagName: el.tagName.toLowerCase(), id: el.id || null,
797 classes: [...el.classList],
798 textContent: (el.textContent || '').slice(0, 500),
799 outerHTML: el.outerHTML.slice(0, 10000),
800 computedStyles: {
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 },
810 cssCustomProperties: props,
811 parentContext: el.parentElement
812 ? '<' + el.parentElement.tagName.toLowerCase()
813 + (el.parentElement.id ? ' id="' + el.parentElement.id + '"' : '')
814 + (el.parentElement.className ? ' class="' + el.parentElement.className + '"' : '')
815 + '>'
816 : null,
817 boundingRect: { width: Math.round(r.width), height: Math.round(r.height) },
818 };
819 }
820
821 // ---------------------------------------------------------------------------
822 // The Bar — one floating element, three modes
823 // ---------------------------------------------------------------------------
824
825 // Contextual-bar palette. Cached at init so every build*Row reads a
826 // consistent set of colors; detectPageTheme runs once rather than on every
827 // phase transition.
828 let BP = null;
829
830 // Bar shadow variants. The default projects down + subtle around. When
831 // the Tune popover opens below the bar, a downward shadow lands on the
832 // dark popover and reads as a bright ghost line. We swap to UP-only while
833 // tune is open below so the popover's top edge is clean.
834 const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
835 const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
836 const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
837
838 function initBar() {
839 BP = barPaletteForTheme(detectPageTheme());
840 barEl = document.createElement('div');
841 barEl.id = PREFIX + '-bar';
842 Object.assign(barEl.style, {
843 position: 'fixed', zIndex: Z.bar,
844 display: 'none', opacity: '0',
845 transform: 'translateY(6px)',
846 transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
847 background: BP.surface,
848 backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
849 border: '1px solid ' + BP.hairline,
850 borderRadius: '10px',
851 boxShadow: BAR_SHADOW_DEFAULT,
852 transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
853 fontFamily: FONT, fontSize: '13px', color: BP.text,
854 padding: '6px',
855 maxWidth: '520px', minWidth: '320px',
856 });
857 document.body.appendChild(barEl);
858 defangOutsideHandlers(barEl);
859 }
860
861 function positionBar() {
862 if (!barEl || !selectedElement) return;
863 const r = selectedElement.getBoundingClientRect();
864 const barH = barEl.offsetHeight || 44;
865 const barW = barEl.offsetWidth || 380;
866 const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room
867 const GAP = 8;
868
869 // Prefer below the element; fall back to above; if neither fits (element
870 // taller than viewport), pin to a stable viewport anchor so the bar
871 // doesn't teleport between top and bottom as the user scrolls.
872 let top;
873 const belowTop = r.bottom + GAP;
874 const aboveTop = r.top - barH - GAP;
875 if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {
876 top = belowTop;
877 } else if (aboveTop >= GAP) {
878 top = aboveTop;
879 } else {
880 top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;
881 }
882
883 let left = r.left + (r.width - barW) / 2;
884 if (left < GAP) left = GAP;
885 if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;
886 Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });
887 }
888
889 function showBar(mode) {
890 barEl.innerHTML = '';
891 if (mode === 'configure') barEl.appendChild(buildConfigureRow());
892 else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
893 else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
894 barEl.style.display = 'block';
895 positionBar();
896 requestAnimationFrame(() => {
897 barEl.style.opacity = '1';
898 barEl.style.transform = 'translateY(0)';
899 });
900 }
901
902 function hideBar() {
903 if (!barEl) return;
904 barEl.style.opacity = '0';
905 barEl.style.transform = 'translateY(6px)';
906 setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
907 hideActionPicker();
908 closeTunePopover();
909 }
910
911 function updateBarContent(mode) {
912 if (!barEl || barEl.style.display === 'none') return;
913 barEl.innerHTML = '';
914 // Reset bar styling to the theme-aware palette
915 barEl.style.background = BP.surface;
916 barEl.style.border = '1px solid ' + BP.hairline;
917 if (mode === 'configure') barEl.appendChild(buildConfigureRow());
918 else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
919 else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
920 else if (mode === 'saving') barEl.appendChild(buildSavingRow());
921 else if (mode === 'confirmed') {
922 barEl.appendChild(buildConfirmedRow());
923 barEl.style.background = 'oklch(95% 0.05 145)';
924 barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)';
925 }
926 }
927
928 // --- Configure row ---
929
930 function buildConfigureRow() {
931 const row = el('div', {
932 display: 'flex', alignItems: 'center', gap: '4px',
933 });
934
935 // Action pill
936 const pill = el('button', {
937 display: 'inline-flex', alignItems: 'center', gap: '4px',
938 padding: '5px 10px', borderRadius: '6px',
939 background: BP.mark, color: BP.markText,
940 fontFamily: FONT, fontSize: '12px', fontWeight: '500',
941 border: 'none', cursor: 'pointer',
942 transition: 'background 0.12s ease, transform 0.1s ease',
943 whiteSpace: 'nowrap', flexShrink: '0',
944 });
945 pill.textContent = actionLabel() + ' \u25BE';
946 pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
947 pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
948 pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
949 pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
950 pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
951 row.appendChild(pill);
952
953 // Freeform input. Focus state shows an accent-colored border only —
954 // an earlier version tinted the background with `BP.accentSoft`, which
955 // composited against the dark bar surface to a murky purple where the
956 // browser's default placeholder gray was unreadable. Placeholder color
957 // is set explicitly via a one-shot stylesheet keyed off this input's id
958 // so it picks up the bar's `textDim` token in both themes.
959 const input = document.createElement('input');
960 input.id = PREFIX + '-input';
961 input.type = 'text';
962 input.placeholder = selectedAction === 'impeccable' ? 'describe what you want...' : 'refine further (optional)...';
963 Object.assign(input.style, {
964 flex: '1', minWidth: '0',
965 padding: '5px 8px', borderRadius: '6px',
966 border: '1px solid transparent', background: 'transparent',
967 fontFamily: FONT, fontSize: '12px', color: BP.text,
968 outline: 'none',
969 transition: 'border-color 0.15s ease',
970 });
971 if (!document.getElementById(PREFIX + '-input-style')) {
972 const s = document.createElement('style');
973 s.id = PREFIX + '-input-style';
974 s.textContent =
975 '#' + PREFIX + '-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }';
976 document.head.appendChild(s);
977 }
978 input.addEventListener('focus', () => {
979 input.style.borderColor = BP.accent;
980 });
981 input.addEventListener('blur', () => {
982 input.style.borderColor = 'transparent';
983 });
984 input.addEventListener('keydown', (e) => {
985 if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; }
986 if (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 empty
988 if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !input.value) return;
989 e.stopPropagation();
990 });
991 row.appendChild(input);
992
993 // Variant count toggle
994 const count = el('button', {
995 padding: '4px 6px', borderRadius: '5px',
996 border: '1px solid ' + BP.hairline, background: 'transparent',
997 fontFamily: MONO, fontSize: '11px', fontWeight: '600',
998 color: BP.textDim, cursor: 'pointer',
999 transition: 'color 0.12s ease, border-color 0.12s ease',
1000 flexShrink: '0', whiteSpace: 'nowrap',
1001 });
1002 count.textContent = '\u00D7' + selectedCount;
1003 count.title = 'Variants: click to change';
1004 count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
1005 count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
1006 count.addEventListener('click', (e) => {
1007 e.stopPropagation();
1008 selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
1009 count.textContent = '\u00D7' + selectedCount;
1010 });
1011 row.appendChild(count);
1012
1013 // Go button
1014 const go = el('button', {
1015 padding: '5px 12px', borderRadius: '6px',
1016 border: 'none', background: BP.accent, color: BP.mark,
1017 fontFamily: FONT, fontSize: '12px', fontWeight: '600',
1018 cursor: 'pointer',
1019 transition: 'filter 0.12s ease, transform 0.1s ease',
1020 flexShrink: '0', whiteSpace: 'nowrap',
1021 });
1022 go.textContent = 'Go \u2192';
1023 go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
1024 go.addEventListener('mouseleave', () => go.style.filter = 'none');
1025 go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
1026 go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
1027 go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
1028 row.appendChild(go);
1029
1030 // Auto-focus input after a beat
1031 setTimeout(() => input.focus(), 60);
1032 return row;
1033 }
1034
1035 // --- Generating row ---
1036
1037 function buildGeneratingRow() {
1038 const row = el('div', {
1039 display: 'flex', alignItems: 'center', gap: '8px',
1040 padding: '2px 4px',
1041 });
1042
1043 // Action label
1044 const label = el('span', {
1045 fontWeight: '600', fontSize: '12px', color: BP.text,
1046 flexShrink: '0', whiteSpace: 'nowrap',
1047 });
1048 label.textContent = actionLabel();
1049 row.appendChild(label);
1050
1051 // Dots
1052 row.appendChild(buildDots(false));
1053
1054 // Status
1055 const status = el('span', {
1056 fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
1057 marginLeft: 'auto',
1058 });
1059 // Variants currently arrive atomically in a single file edit, so a
1060 // per-variant counter would lie. Say what's true.
1061 status.textContent = arrivedVariants < expectedVariants
1062 ? 'Generating ' + expectedVariants + ' variants...'
1063 : 'Done';
1064 row.appendChild(status);
1065
1066 return row;
1067 }
1068
1069 // --- Cycling row ---
1070
1071 const 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>';
1072
1073 function buildCyclingRow() {
1074 const row = el('div', {
1075 display: 'flex', alignItems: 'center', gap: '6px',
1076 padding: '1px 2px',
1077 });
1078
1079 // Prev
1080 const prev = navBtn('\u2190');
1081 prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); });
1082 if (visibleVariant <= 1) prev.style.opacity = '0.3';
1083 row.appendChild(prev);
1084
1085 // Dots (clickable)
1086 row.appendChild(buildDots(true));
1087
1088 // Counter
1089 const counter = el('span', {
1090 fontFamily: MONO, fontSize: '11px', fontWeight: '500',
1091 color: BP.textDim, minWidth: '24px', textAlign: 'center',
1092 });
1093 counter.textContent = visibleVariant + '/' + arrivedVariants;
1094 row.appendChild(counter);
1095
1096 // Next
1097 const next = navBtn('\u2192');
1098 next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); });
1099 if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
1100 row.appendChild(next);
1101
1102 // Tune chip — only when the visible variant exposes params
1103 const visParams = parseVariantParams(getVisibleVariantEl());
1104 const hasParams = visParams.length > 0;
1105 if (hasParams) {
1106 const tune = el('button', {
1107 display: 'inline-flex', alignItems: 'center', gap: '6px',
1108 padding: '4px 10px', borderRadius: '5px',
1109 border: '1px solid transparent',
1110 background: tuneOpen ? BP.accentSoft : 'transparent',
1111 color: tuneOpen ? BP.accent : BP.text,
1112 fontFamily: FONT, fontSize: '11px', fontWeight: '500',
1113 cursor: 'pointer',
1114 transition: 'color 0.12s ease, background 0.12s ease',
1115 whiteSpace: 'nowrap',
1116 });
1117 tune.innerHTML = TUNE_ICON_SVG;
1118 const tuneLabel = document.createElement('span');
1119 tuneLabel.textContent = 'Tune';
1120 tune.appendChild(tuneLabel);
1121 const tuneBadge = document.createElement('span');
1122 Object.assign(tuneBadge.style, {
1123 display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
1124 minWidth: '16px', height: '16px', padding: '0 4px',
1125 borderRadius: '999px',
1126 background: tuneOpen ? C.brand : BP.hairline,
1127 color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
1128 fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
1129 lineHeight: '1',
1130 boxSizing: 'border-box',
1131 });
1132 tuneBadge.textContent = String(visParams.length);
1133 tune.appendChild(tuneBadge);
1134 tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
1135 tune.addEventListener('mouseenter', () => {
1136 if (!tuneOpen) tune.style.background = BP.accentSoft;
1137 });
1138 tune.addEventListener('mouseleave', () => {
1139 if (!tuneOpen) tune.style.background = 'transparent';
1140 });
1141 tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
1142 tune.dataset.iceqTune = '1';
1143 row.appendChild(tune);
1144 }
1145
1146 // Spacer
1147 row.appendChild(el('div', { flex: '1' }));
1148
1149 // Accept — primary action, uses the site's saturated brand magenta
1150 // with paper-white text, not the theme-muted BP.accent.
1151 const accept = el('button', {
1152 padding: '5px 14px', borderRadius: '5px',
1153 border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
1154 fontFamily: FONT, fontSize: '11px', fontWeight: '600',
1155 cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
1156 whiteSpace: 'nowrap',
1157 });
1158 accept.textContent = '\u2713 Accept';
1159 accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
1160 accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
1161 accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
1162 accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
1163 accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
1164 if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
1165 row.appendChild(accept);
1166
1167 // Discard
1168 const discard = el('button', {
1169 padding: '4px 6px', borderRadius: '5px',
1170 border: '1px solid ' + BP.hairline, background: 'transparent',
1171 fontFamily: FONT, fontSize: '11px', color: BP.textDim,
1172 cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
1173 });
1174 discard.textContent = '\u2715';
1175 discard.title = 'Discard all variants';
1176 discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
1177 discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
1178 discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
1179 row.appendChild(discard);
1180
1181 return row;
1182 }
1183
1184 // --- Shared UI builders ---
1185
1186 // --- Saving row (waiting for agent to process accept/discard) ---
1187
1188 function buildSavingRow() {
1189 const row = el('div', {
1190 display: 'flex', alignItems: 'center', gap: '8px',
1191 padding: '2px 8px',
1192 });
1193 const spinner = el('div', {
1194 width: '14px', height: '14px', borderRadius: '50%',
1195 border: '2px solid ' + BP.hairline,
1196 borderTopColor: BP.accent,
1197 animation: 'impeccable-spin 0.6s linear infinite',
1198 flexShrink: '0',
1199 });
1200 row.appendChild(spinner);
1201 const label = el('span', {
1202 fontSize: '12px', color: BP.textDim, fontWeight: '500',
1203 });
1204 label.textContent = 'Applying variant...';
1205 row.appendChild(label);
1206
1207 // Inject the keyframes if not already present
1208 if (!document.getElementById(PREFIX + '-keyframes')) {
1209 const style = document.createElement('style');
1210 style.id = PREFIX + '-keyframes';
1211 style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }';
1212 document.head.appendChild(style);
1213 }
1214 return row;
1215 }
1216
1217 // --- Confirmed row (green success, auto-dismisses) ---
1218
1219 function buildConfirmedRow() {
1220 const row = el('div', {
1221 display: 'flex', alignItems: 'center', gap: '8px',
1222 padding: '2px 8px',
1223 });
1224 const check = el('span', {
1225 fontSize: '15px', lineHeight: '1', flexShrink: '0',
1226 color: 'oklch(45% 0.15 145)',
1227 });
1228 check.textContent = '\u2713';
1229 row.appendChild(check);
1230 const label = el('span', {
1231 fontSize: '12px', color: 'oklch(35% 0.1 145)', fontWeight: '600',
1232 });
1233 label.textContent = 'Variant applied';
1234 row.appendChild(label);
1235 return row;
1236 }
1237
1238 // --- Shared UI builders ---
1239
1240 function buildDots(clickable) {
1241 const container = el('div', {
1242 display: 'flex', alignItems: 'center', gap: '4px',
1243 });
1244 for (let i = 1; i <= expectedVariants; i++) {
1245 const arrived = i <= arrivedVariants;
1246 const 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 arrived
1249 // dots — the previous "accent ring + ash fill" combo read as noisy
1250 // magenta chips, especially when all variants had arrived and every
1251 // dot wore an accent ring.
1252 const dotBg = active ? C.brand
1253 : arrived ? BP.textDim
1254 : 'transparent';
1255 const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
1256 const dot = el('div', {
1257 width: active ? '8px' : '6px',
1258 height: active ? '8px' : '6px',
1259 borderRadius: '50%',
1260 background: dotBg,
1261 border: dotBorder,
1262 boxSizing: 'border-box',
1263 transition: 'all 0.2s ' + EASE,
1264 cursor: (clickable && arrived) ? 'pointer' : 'default',
1265 transform: arrived ? 'scale(1)' : 'scale(0.85)',
1266 opacity: arrived ? (active ? '1' : '0.6') : '0.4',
1267 });
1268 if (clickable && arrived) {
1269 const idx = i;
1270 dot.addEventListener('click', (e) => {
1271 e.stopPropagation();
1272 visibleVariant = idx;
1273 showVariantInDOM(currentSessionId, idx);
1274 updateSelectedElement();
1275 updateBarContent('cycling');
1276 });
1277 }
1278 container.appendChild(dot);
1279 }
1280 return container;
1281 }
1282
1283 function navBtn(text) {
1284 const b = el('button', {
1285 width: '26px', height: '26px', borderRadius: '5px',
1286 border: '1px solid ' + BP.hairline, background: 'transparent',
1287 color: BP.text, fontFamily: FONT, fontSize: '13px',
1288 cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
1289 transition: 'border-color 0.12s ease, background 0.12s ease',
1290 padding: '0', lineHeight: '1',
1291 });
1292 b.textContent = text;
1293 b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
1294 b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
1295 return b;
1296 }
1297
1298 function actionLabel() {
1299 const a = ACTIONS.find(a => a.value === selectedAction);
1300 return a ? a.label : 'Freeform';
1301 }
1302
1303 function el(tag, styles) {
1304 const e = document.createElement(tag);
1305 if (styles) Object.assign(e.style, styles);
1306 return e;
1307 }
1308
1309 // ---------------------------------------------------------------------------
1310 // Action picker popover
1311 // ---------------------------------------------------------------------------
1312
1313 function initActionPicker() {
1314 const P = barPaletteForTheme(detectPageTheme());
1315 pickerEl = document.createElement('div');
1316 pickerEl.id = PREFIX + '-picker';
1317 Object.assign(pickerEl.style, {
1318 position: 'fixed', zIndex: Z.picker,
1319 display: 'none', opacity: '0',
1320 transform: 'scale(0.96) translateY(4px)',
1321 transformOrigin: 'bottom left',
1322 transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
1323 background: P.surface,
1324 border: '1px solid ' + P.hairline,
1325 borderRadius: '10px',
1326 boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
1327 padding: '6px',
1328 fontFamily: FONT,
1329 backdropFilter: 'blur(10px)',
1330 WebkitBackdropFilter: 'blur(10px)',
1331 });
1332
1333 // Build the chip grid
1334 const grid = el('div', {
1335 display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '3px',
1336 });
1337
1338 ACTIONS.forEach(action => {
1339 const chip = el('button', {
1340 display: 'flex', flexDirection: 'column', alignItems: 'center',
1341 gap: '4px',
1342 padding: '8px 6px', borderRadius: '6px',
1343 border: 'none',
1344 background: action.value === selectedAction ? P.accentSoft : 'transparent',
1345 color: action.value === selectedAction ? P.accent : P.text,
1346 fontFamily: FONT, fontSize: '11px', fontWeight: '500',
1347 cursor: 'pointer',
1348 transition: 'background 0.1s ease, color 0.1s ease',
1349 textAlign: 'center', whiteSpace: 'nowrap',
1350 });
1351 const iconWrap = el('span', {
1352 display: 'flex', alignItems: 'center', justifyContent: 'center',
1353 height: '20px', opacity: '0.9',
1354 });
1355 iconWrap.innerHTML = ICONS[action.value] || '';
1356 const labelEl = el('span', { lineHeight: '1' });
1357 labelEl.textContent = action.label;
1358 chip.appendChild(iconWrap);
1359 chip.appendChild(labelEl);
1360 chip.dataset.action = action.value;
1361 chip.addEventListener('mouseenter', () => {
1362 if (action.value !== selectedAction) chip.style.background = P.accentSoft;
1363 });
1364 chip.addEventListener('mouseleave', () => {
1365 chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
1366 });
1367 chip.addEventListener('click', (e) => {
1368 e.stopPropagation();
1369 selectedAction = action.value;
1370 hideActionPicker();
1371 updateBarContent('configure');
1372 });
1373 grid.appendChild(chip);
1374 });
1375
1376 pickerEl.appendChild(grid);
1377 document.body.appendChild(pickerEl);
1378 defangOutsideHandlers(pickerEl);
1379
1380 // Cache the palette on the picker so toggleActionPicker's state refresh
1381 // uses the same theme-aware colors when it repaints chips.
1382 pickerEl.__iceq_palette = P;
1383 }
1384
1385 function toggleActionPicker() {
1386 if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
1387 // Rebuild chips to reflect current selection
1388 const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
1389 pickerEl.querySelectorAll('button').forEach(chip => {
1390 const isActive = chip.dataset.action === selectedAction;
1391 chip.style.background = isActive ? P.accentSoft : 'transparent';
1392 chip.style.color = isActive ? P.accent : P.text;
1393 });
1394 // Position above the bar
1395 const barRect = barEl.getBoundingClientRect();
1396 const pickerH = 170; // approximate; grows with icon + label rows
1397 let top = barRect.top - pickerH - 6;
1398 if (top < 8) top = barRect.bottom + 6;
1399 Object.assign(pickerEl.style, {
1400 top: top + 'px', left: barRect.left + 'px',
1401 display: 'block',
1402 });
1403 requestAnimationFrame(() => {
1404 pickerEl.style.opacity = '1';
1405 pickerEl.style.transform = 'scale(1) translateY(0)';
1406 });
1407 }
1408
1409 function hideActionPicker() {
1410 if (!pickerEl) return;
1411 pickerEl.style.opacity = '0';
1412 pickerEl.style.transform = 'scale(0.96) translateY(4px)';
1413 setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
1414 }
1415
1416 // ---------------------------------------------------------------------------
1417 // Params panel (per-variant coarse controls)
1418 //
1419 // Variants may declare a parameter manifest via a JSON attribute on the
1420 // 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 and
1426 // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
1427 // 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 agent
1434 // can bake them into the source-file write.
1435 // ---------------------------------------------------------------------------
1436
1437 let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
1438 let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
1439 let paramsPanelBody = null; // grid holding the knob cells
1440 let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
1441 let tuneOpen = false; // whether the Tune popover is open right now
1442
1443 // Theme-aware Tune popover. Appears as a drawer that slides out from the
1444 // contextual bar's bar-facing edge (below if the bar sits below the
1445 // element, above otherwise). Same width as the bar. Auto-wraps to extra
1446 // rows when the knobs exceed one row. The bar's border-radius on the
1447 // popover side goes flat while open so the two shapes read as one.
1448 let paramsPanelPalette = null;
1449
1450 function initParamsPanel() {
1451 paramsPanelPalette = barPaletteForTheme(detectPageTheme());
1452 const P = paramsPanelPalette;
1453
1454 // Single element, always in the DOM. The slide animation is a CSS mask
1455 // with mask-size growing from 0% to 100% along the bar-facing axis — no
1456 // display toggle, no opacity toggle, no transform trickery. The mask
1457 // hides everything initially; as it grows, content is revealed from
1458 // the bar edge outward.
1459 paramsPanelEl = document.createElement('div');
1460 paramsPanelEl.id = PREFIX + '-params-panel';
1461 Object.assign(paramsPanelEl.style, {
1462 position: 'fixed', zIndex: String(Z.bar - 1),
1463 background: P.surfaceDeep,
1464 color: P.text,
1465 fontFamily: FONT,
1466 padding: '14px 18px',
1467 boxSizing: 'border-box',
1468 borderRadius: '0 0 10px 10px',
1469 pointerEvents: 'none',
1470 backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
1471
1472 // clip-path is the same conceptual reveal as mask but with rock-solid
1473 // transition support across engines. Closed state clips from the far
1474 // edge; open = inset(0) shows everything.
1475 clipPath: 'inset(0 0 100% 0)',
1476 transition: 'clip-path 0.44s ' + EASE,
1477
1478 // Park off-screen until positionParamsPanel places it. These are NOT
1479 // in the transition list, so they snap instantly — no fly-in from the
1480 // top-left when first shown.
1481 top: '-9999px', left: '-9999px', width: '0',
1482 });
1483
1484 paramsPanelBody = el('div', {
1485 display: 'grid',
1486 gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
1487 gap: '12px 16px',
1488 });
1489
1490 paramsPanelEl.appendChild(paramsPanelBody);
1491 document.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's
1494 // outside-interaction listeners while the panel is open.
1495 defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
1496 paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
1497 }
1498
1499 function getVisibleVariantEl() {
1500 if (!currentSessionId) return null;
1501 const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
1502 if (!wrapper) return null;
1503 return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
1504 }
1505
1506 function parseVariantParams(variantEl) {
1507 if (!variantEl) return [];
1508 const raw = variantEl.getAttribute('data-impeccable-params');
1509 if (!raw) return [];
1510 try {
1511 const parsed = JSON.parse(raw);
1512 return Array.isArray(parsed) ? parsed : [];
1513 } catch (err) {
1514 console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
1515 return [];
1516 }
1517 }
1518
1519 function applyParamValue(variantEl, param, value) {
1520 if (!variantEl) return;
1521 const attr = 'data-p-' + param.id;
1522 if (param.kind === 'range') {
1523 variantEl.style.setProperty('--p-' + param.id, String(value));
1524 } else if (param.kind === 'toggle') {
1525 const on = !!value;
1526 variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
1527 if (on) variantEl.setAttribute(attr, 'on');
1528 else variantEl.removeAttribute(attr);
1529 } else if (param.kind === 'steps') {
1530 variantEl.setAttribute(attr, String(value));
1531 }
1532 }
1533
1534 function applyParamDefaults(variantEl, params) {
1535 paramsCurrentValues = {};
1536 for (const p of params) {
1537 paramsCurrentValues[p.id] = p.default;
1538 applyParamValue(variantEl, p, p.default);
1539 }
1540 }
1541
1542 function formatRangeValue(input) {
1543 const max = parseFloat(input.max), min = parseFloat(input.min);
1544 const v = parseFloat(input.value);
1545 if (!isFinite(v)) return input.value;
1546 return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
1547 }
1548
1549 function buildParamsPanel(variantEl, params) {
1550 const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
1551 paramsPanelBody.innerHTML = '';
1552 for (const p of params) {
1553 const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
1554 const labelRow = el('div', {
1555 display: 'flex', justifyContent: 'space-between',
1556 alignItems: 'baseline', gap: '8px',
1557 });
1558 const lbl = el('span', {
1559 fontSize: '10.5px', fontWeight: '600', color: P.text,
1560 letterSpacing: '0.03em',
1561 });
1562 lbl.textContent = p.label || p.id;
1563 labelRow.appendChild(lbl);
1564 const readout = el('span', {
1565 fontSize: '10.5px', color: P.textDim,
1566 fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
1567 });
1568 labelRow.appendChild(readout);
1569 row.appendChild(labelRow);
1570
1571 if (p.kind === 'range') {
1572 const input = document.createElement('input');
1573 input.type = 'range';
1574 input.min = String(p.min != null ? p.min : 0);
1575 input.max = String(p.max != null ? p.max : 1);
1576 input.step = String(p.step != null ? p.step : 0.05);
1577 input.value = String(p.default);
1578 Object.assign(input.style, {
1579 width: '100%', accentColor: C.brand, cursor: 'pointer',
1580 });
1581 readout.textContent = formatRangeValue(input);
1582 input.addEventListener('input', (e) => {
1583 e.stopPropagation();
1584 const v = parseFloat(input.value);
1585 paramsCurrentValues[p.id] = v;
1586 readout.textContent = formatRangeValue(input);
1587 applyParamValue(variantEl, p, v);
1588 queueCheckpoint('param_changed');
1589 });
1590 row.appendChild(input);
1591 } else if (p.kind === 'toggle') {
1592 const initial = !!p.default;
1593 readout.textContent = initial ? 'On' : 'Off';
1594 const track = el('button', {
1595 position: 'relative', width: '36px', height: '20px',
1596 borderRadius: '10px', border: 'none', padding: '0',
1597 cursor: 'pointer',
1598 background: initial ? C.brand : P.hairline,
1599 transition: 'background 0.15s ease',
1600 alignSelf: 'flex-start',
1601 });
1602 const knob = el('span', {
1603 position: 'absolute', top: '2px',
1604 left: initial ? '18px' : '2px',
1605 width: '16px', height: '16px', borderRadius: '50%',
1606 background: 'oklch(98% 0 0)',
1607 transition: 'left 0.18s ' + EASE,
1608 boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
1609 });
1610 track.appendChild(knob);
1611 track.addEventListener('click', (e) => {
1612 e.stopPropagation();
1613 const next = !paramsCurrentValues[p.id];
1614 paramsCurrentValues[p.id] = next;
1615 track.style.background = next ? C.brand : P.hairline;
1616 knob.style.left = next ? '18px' : '2px';
1617 readout.textContent = next ? 'On' : 'Off';
1618 applyParamValue(variantEl, p, next);
1619 queueCheckpoint('param_changed');
1620 });
1621 row.appendChild(track);
1622 } else if (p.kind === 'steps') {
1623 const opts = (p.options || []).map(o =>
1624 typeof o === 'string' ? { value: o, label: o } : o
1625 );
1626 const activeOpt = opts.find(o => o.value === p.default) || opts[0];
1627 readout.textContent = activeOpt ? activeOpt.label : String(p.default);
1628 const segRow = el('div', {
1629 display: 'grid',
1630 gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
1631 gap: '1px', padding: '2px',
1632 background: P.hairline, borderRadius: '5px',
1633 });
1634 const segBtns = [];
1635 opts.forEach(o => {
1636 const active = o.value === p.default;
1637 const b = el('button', {
1638 padding: '5px 4px', border: 'none', borderRadius: '3px',
1639 background: active ? C.brand : 'transparent',
1640 color: active ? 'oklch(98% 0 0)' : P.text,
1641 fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
1642 cursor: 'pointer', whiteSpace: 'nowrap',
1643 transition: 'background 0.1s ease, color 0.1s ease',
1644 });
1645 b.textContent = o.label;
1646 b.addEventListener('click', (e) => {
1647 e.stopPropagation();
1648 paramsCurrentValues[p.id] = o.value;
1649 readout.textContent = o.label;
1650 segBtns.forEach(({ btn, val }) => {
1651 const on = val === o.value;
1652 btn.style.background = on ? C.brand : 'transparent';
1653 btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
1654 });
1655 applyParamValue(variantEl, p, o.value);
1656 queueCheckpoint('param_changed');
1657 });
1658 segRow.appendChild(b);
1659 segBtns.push({ btn: b, val: o.value });
1660 });
1661 row.appendChild(segRow);
1662 }
1663
1664 paramsPanelBody.appendChild(row);
1665 }
1666 }
1667
1668 // Decide which way the popover opens: away from the picked element. If the
1669 // 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.
1671 function popoverDirection() {
1672 if (!barEl || !selectedElement) return 'below';
1673 const br = barEl.getBoundingClientRect();
1674 const er = selectedElement.getBoundingClientRect();
1675 return br.top >= er.bottom - 4 ? 'below' : 'above';
1676 }
1677
1678 // The popover overlaps the bar by OVERLAP px on the bar-facing side. With
1679 // popover z-index below bar, that overlap sits behind bar (invisible) and
1680 // reinforces the "tucked behind" feel. Padding compensates so the real
1681 // content starts flush with bar's outer edge.
1682 const TUNE_OVERLAP = 6;
1683
1684 // Closed clip-path depends on direction: for 'below' clip from the far
1685 // (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.
1687 function closedClipPath(direction) {
1688 return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
1689 }
1690
1691 function setClipPath(value, withTransition) {
1692 const saved = paramsPanelEl.style.transition;
1693 if (!withTransition) paramsPanelEl.style.transition = 'none';
1694 paramsPanelEl.style.clipPath = value;
1695 if (!withTransition) {
1696 void paramsPanelEl.offsetHeight;
1697 paramsPanelEl.style.transition = saved;
1698 }
1699 }
1700
1701 function positionParamsPanel() {
1702 if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
1703 const br = barEl.getBoundingClientRect();
1704 const direction = popoverDirection();
1705 const prevDirection = paramsPanelEl.dataset.tuneDirection;
1706
1707 // top/left/width are NOT in the transition list, so they snap instantly.
1708 paramsPanelEl.style.left = br.left + 'px';
1709 paramsPanelEl.style.width = br.width + 'px';
1710
1711 if (direction === 'below') {
1712 paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
1713 paramsPanelEl.style.borderRadius = '0 0 10px 10px';
1714 paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
1715 paramsPanelEl.style.paddingBottom = '14px';
1716 } else {
1717 const ih = paramsPanelEl.offsetHeight || 80;
1718 paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
1719 paramsPanelEl.style.borderRadius = '10px 10px 0 0';
1720 paramsPanelEl.style.paddingTop = '14px';
1721 paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
1722 }
1723 paramsPanelEl.dataset.tuneDirection = direction;
1724
1725 // If currently closed and direction flipped (or first-time setup),
1726 // snap the clip-path to the new direction's closed pose without
1727 // transitioning (so the clip doesn't slide across the element).
1728 if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
1729 setClipPath(closedClipPath(direction), false);
1730 }
1731 }
1732
1733 function showParamsPanel() {
1734 if (!paramsPanelEl) return;
1735 positionParamsPanel();
1736 paramsPanelEl.style.pointerEvents = 'auto';
1737 // rAF so the positioning paint commits before the transition fires.
1738 requestAnimationFrame(() => {
1739 setClipPath('inset(0 0 0 0)', true);
1740 });
1741 }
1742
1743 function hideParamsPanel() {
1744 if (!paramsPanelEl) return;
1745 paramsPanelEl.style.pointerEvents = 'none';
1746 const direction = paramsPanelEl.dataset.tuneDirection || 'below';
1747 setClipPath(closedClipPath(direction), true);
1748 }
1749
1750 // Build/rebuild the panel's contents for the current variant AND apply
1751 // its defaults to the variant wrapper (so scoped CSS responds even before
1752 // the user opens the popover). Visibility is governed by tuneOpen.
1753 function refreshParamsPanel() {
1754 if (state !== 'CYCLING') {
1755 paramsCurrentValues = {};
1756 tuneOpen = false;
1757 hideParamsPanel();
1758 return;
1759 }
1760 const variantEl = getVisibleVariantEl();
1761 const params = parseVariantParams(variantEl);
1762 if (!variantEl || params.length === 0) {
1763 paramsCurrentValues = {};
1764 tuneOpen = false;
1765 hideParamsPanel();
1766 return;
1767 }
1768 applyParamDefaults(variantEl, params);
1769 buildParamsPanel(variantEl, params);
1770 if (tuneOpen) {
1771 // If already visible (variant cycled while open), refresh in place
1772 // instead of re-running the clip-path animation.
1773 const alreadyVisible = paramsPanelEl.style.display === 'block'
1774 && paramsPanelEl.style.opacity === '1';
1775 if (alreadyVisible) positionParamsPanel();
1776 else showParamsPanel();
1777 } else {
1778 hideParamsPanel();
1779 }
1780 }
1781
1782 function toggleTunePopover() {
1783 if (tuneOpen) { closeTunePopover(); return; }
1784 openTunePopover();
1785 }
1786
1787 function openTunePopover() {
1788 if (state !== 'CYCLING') return;
1789 const variantEl = getVisibleVariantEl();
1790 const params = parseVariantParams(variantEl);
1791 if (!variantEl || params.length === 0) return;
1792 // Build fresh to ensure the current variant's controls are shown.
1793 applyParamDefaults(variantEl, params);
1794 buildParamsPanel(variantEl, params);
1795 tuneOpen = true;
1796 showParamsPanel();
1797 // Kill the bar's shadow on the popover-facing side so the dark popover
1798 // doesn't pick up a bright glow line.
1799 if (barEl) {
1800 const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
1801 barEl.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.
1804 updateBarContent('cycling');
1805 }
1806
1807 function closeTunePopover() {
1808 tuneOpen = false;
1809 hideParamsPanel();
1810 if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
1811 if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
1812 updateBarContent('cycling');
1813 }
1814 }
1815
1816 // ---------------------------------------------------------------------------
1817 // Variant cycling in DOM
1818 // ---------------------------------------------------------------------------
1819
1820 function showVariantInDOM(sessionId, num) {
1821 const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
1822 if (!wrapper) return;
1823 for (const child of wrapper.children) {
1824 const v = child.dataset ? child.dataset.impeccableVariant : null;
1825 if (!v) continue;
1826 child.style.display = (v === String(num)) ? '' : 'none';
1827 }
1828 // Unconditional refresh — covers first-reveal (no-op if state isn't
1829 // CYCLING yet, the subsequent CYCLING transition triggers its own
1830 // refresh) and every cycle step.
1831 refreshParamsPanel();
1832 }
1833
1834 /**
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 */
1839 function injectVariantsFromSource(filePath, sessionId) {
1840 const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
1841 fetch(url)
1842 .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); })
1843 .then(html => {
1844 // Parse the raw source HTML
1845 const parser = new DOMParser();
1846 const doc = parser.parseFromString(html, 'text/html');
1847 const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]');
1848 if (!srcWrapper) {
1849 console.error('[impeccable] Variant wrapper not found in source file.');
1850 return;
1851 }
1852
1853 // Find the original element in the live DOM.
1854 // The original is inside the wrapper in the source. We find the
1855 // corresponding element in the live DOM by matching the first child's
1856 // tag + classes from the original snapshot.
1857 const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child');
1858 if (!origContent) return;
1859
1860 const tag = origContent.tagName.toLowerCase();
1861 const cls = origContent.className;
1862 let liveEl = null;
1863 if (origContent.id) {
1864 liveEl = document.getElementById(origContent.id);
1865 } else if (cls) {
1866 // Find by tag + exact class match
1867 const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]);
1868 for (const c of candidates) {
1869 if (c.className === cls && !own(c)) { liveEl = c; break; }
1870 }
1871 }
1872
1873 if (!liveEl) {
1874 console.error('[impeccable] Could not find original element in live DOM.');
1875 return;
1876 }
1877
1878 const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0;
1879
1880 // Replace the live element with the full wrapper from source
1881 const wrapper = srcWrapper.cloneNode(true);
1882 liveEl.parentElement.replaceChild(wrapper, liveEl);
1883
1884 // Update state: count variants, preserving the user's current variant
1885 // when a late HMR/source reinjection lands after they have cycled.
1886 const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
1887 arrivedVariants = variants.length;
1888 expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);
1889 const saved = loadSession();
1890 const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
1891 visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants
1892 ? previousVisibleVariant
1893 : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
1894 showVariantInDOM(sessionId, visibleVariant);
1895
1896 // Update selectedElement to the visible variant's content
1897 selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement;
1898
1899 state = 'CYCLING';
1900 hideShaderOverlay();
1901 updateBarContent('cycling');
1902 refreshParamsPanel();
1903 saveSession();
1904 console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
1905 })
1906 .catch(err => {
1907 console.error('[impeccable] Failed to fetch source:', err);
1908 showToast('Could not load variants. Try refreshing the page.', 5000);
1909 });
1910 }
1911
1912 function cycleVariant(dir) {
1913 const next = visibleVariant + dir;
1914 if (next < 1 || next > arrivedVariants) return;
1915 visibleVariant = next;
1916 showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
1917 updateSelectedElement();
1918 updateBarContent('cycling');
1919 saveSession();
1920 queueCheckpoint('variant_changed');
1921 }
1922
1923 function updateSelectedElement() {
1924 if (!currentSessionId) return;
1925 const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
1926 if (!wrapper) return;
1927 const visEl = pickVariantContent(wrapper, visibleVariant);
1928 if (visEl) selectedElement = visEl;
1929 }
1930
1931 function readVisibleVariantFromDOM(sessionId) {
1932 const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
1933 if (!wrapper) return 0;
1934 const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
1935 for (const variant of variants) {
1936 if (variant.style.display === 'none') continue;
1937 const idx = parseInt(variant.dataset.impeccableVariant || '0', 10);
1938 if (idx > 0) return idx;
1939 }
1940 return 0;
1941 }
1942
1943 // Resolve the element that represents the variant's visible content.
1944 // Contract: each variant div should contain exactly one top-level element
1945 // (the full replacement). In practice a model may ship loose siblings or
1946 // lead with <style>/<script>. Be defensive: skip non-visual elements, and
1947 // if the variant has multiple element children, use the variant div itself
1948 // (it wraps all of them and gets correct bounds).
1949 function pickVariantContent(wrapper, index) {
1950 if (!wrapper) return null;
1951 const variantDiv = wrapper.querySelector('[data-impeccable-variant="' + index + '"]');
1952 if (!variantDiv) return null;
1953 const NON_VISUAL = new Set(['STYLE', 'SCRIPT', 'LINK', 'META', 'TEMPLATE']);
1954 const visual = [];
1955 for (const child of variantDiv.children) {
1956 if (!NON_VISUAL.has(child.tagName)) visual.push(child);
1957 }
1958 if (visual.length === 1) return visual[0];
1959 return variantDiv;
1960 }
1961
1962 // Hold window.scrollY at a fixed value across DOM mutations inside the
1963 // session's wrapper (HMR patches, variant inserts, cycle swaps).
1964 function startScrollLock(sessionId, initialTargetY) {
1965 stopScrollLock();
1966 scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
1967 ? initialTargetY
1968 : window.scrollY;
1969 console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY });
1970
1971 try { history.scrollRestoration = 'manual'; } catch {}
1972
1973 const prevHtmlAnchor = document.documentElement.style.overflowAnchor;
1974 const prevBodyAnchor = document.body.style.overflowAnchor;
1975 document.documentElement.style.overflowAnchor = 'none';
1976 document.body.style.overflowAnchor = 'none';
1977
1978 const correct = (why) => {
1979 scrollLockRaf = null;
1980 if (scrollLockTargetY == null) return;
1981 const before = window.scrollY;
1982 const delta = before - scrollLockTargetY;
1983 if (Math.abs(delta) < 0.5) {
1984 console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY });
1985 return;
1986 }
1987 window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
1988 console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY });
1989 };
1990 const schedule = (why) => {
1991 if (scrollLockRaf != null) return;
1992 scrollLockRaf = requestAnimationFrame(() => correct(why));
1993 };
1994
1995 scrollLockObserver = new MutationObserver((mutations) => {
1996 for (const m of mutations) {
1997 if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) {
1998 const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(',');
1999 console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY });
2000 schedule('mutation-in-wrapper');
2001 return;
2002 }
2003 for (const n of m.addedNodes) {
2004 if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) {
2005 console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY });
2006 schedule('wrapper-added');
2007 return;
2008 }
2009 }
2010 }
2011 });
2012 scrollLockObserver.observe(document.body, { childList: true, subtree: true });
2013
2014 scrollLockAbort = new AbortController();
2015 scrollLockAbort.signal.addEventListener('abort', () => {
2016 document.documentElement.style.overflowAnchor = prevHtmlAnchor;
2017 document.body.style.overflowAnchor = prevBodyAnchor;
2018 }, { once: true });
2019 const sig = { signal: scrollLockAbort.signal };
2020 // Track whether the most recent scroll came from a user gesture. We
2021 // gate user-scroll re-anchoring on this flag so programmatic smooth
2022 // scrolls (browser reload-restore, scrollIntoView from other scripts)
2023 // don't accidentally update our target.
2024 let userGestureAt = 0;
2025 const USER_GESTURE_WINDOW_MS = 250;
2026
2027 const reanchor = (why) => {
2028 if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
2029 const prevTarget = scrollLockTargetY;
2030 scrollLockTargetY = window.scrollY;
2031 writeScrollY(scrollLockTargetY);
2032 console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY });
2033 };
2034 const markGesture = (why) => {
2035 userGestureAt = performance.now();
2036 reanchor(why);
2037 };
2038 window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });
2039 window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });
2040 window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });
2041 window.addEventListener('keydown', (e) => {
2042 if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);
2043 }, sig);
2044
2045 // Correct on EVERY scroll event: whether it's the browser's
2046 // post-reload animated restore or some other script calling
2047 // scrollIntoView, we want to snap back immediately. Only skip if a
2048 // user gesture fired in the last 250ms.
2049 let lastLoggedScrollY = window.scrollY;
2050 window.addEventListener('scroll', () => {
2051 const now = window.scrollY;
2052 if (Math.abs(now - lastLoggedScrollY) > 5) {
2053 console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY });
2054 lastLoggedScrollY = now;
2055 }
2056 if (scrollLockTargetY == null) return;
2057 if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;
2058 if (Math.abs(now - scrollLockTargetY) < 0.5) return;
2059 console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY });
2060 window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
2061 }, { passive: true, ...sig });
2062
2063 // Apply target synchronously, not via rAF — racing the browser's
2064 // restore or a smooth-scroll animation means we want to win now.
2065 if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
2066 window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
2067 console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY });
2068 }
2069 }
2070
2071 function stopScrollLock() {
2072 if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
2073 if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
2074 if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
2075 scrollLockTargetY = null;
2076 // NOTE: do NOT clear the persistent scroll key here. startScrollLock
2077 // calls us as a reset, and clearing the key would nuke the Go-time
2078 // scrollY that the next resume needs to read.
2079 }
2080
2081 // ---------------------------------------------------------------------------
2082 // MutationObserver for progressive variant reveal
2083 // ---------------------------------------------------------------------------
2084
2085 function startVariantObserver(sessionId) {
2086 let updating = false; // re-entrancy guard
2087
2088 const obs = new MutationObserver((mutations) => {
2089 if (updating) return;
2090
2091 // Only react to mutations that add nodes with data-impeccable-variant,
2092 // or mutations inside the variant wrapper. Ignore our own bar/UI changes.
2093 let dominated = false;
2094 for (const m of mutations) {
2095 if (m.target.closest?.('[data-impeccable-variants]')) { dominated = true; break; }
2096 for (const n of m.addedNodes) {
2097 if (n.nodeType !== 1) continue;
2098 // Direct hit: the added node itself is the wrapper or a variant.
2099 if (n.dataset?.impeccableVariants || n.dataset?.impeccableVariant) {
2100 dominated = true; break;
2101 }
2102 // Subtree hit: framework HMR (notably SvelteKit) sometimes replaces
2103 // a whole subtree where the wrapper is a descendant of the added
2104 // node. Without this check, the observer ignores those mutations
2105 // and the session stays in GENERATING forever.
2106 if (n.querySelector?.('[data-impeccable-variants],[data-impeccable-variant]')) {
2107 dominated = true; break;
2108 }
2109 }
2110 if (dominated) break;
2111 }
2112 if (!dominated) return;
2113
2114 const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
2115 if (!wrapper) return;
2116
2117 // Re-anchor selectedElement if it was detached by live-wrap's HMR swap.
2118 // Without this, the shader / highlight / bar track a zero-rect phantom
2119 // and the overlay appears frozen.
2120 if (selectedElement && !document.body.contains(selectedElement)) {
2121 selectedElement = pickVariantContent(wrapper, 'original') || wrapper;
2122 }
2123
2124 const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
2125 const count = variants.length;
2126
2127 // Nothing new
2128 if (count <= arrivedVariants) return;
2129
2130 updating = true;
2131 arrivedVariants = count;
2132 if (visibleVariant === 0 && arrivedVariants > 0) {
2133 const saved = loadSession();
2134 const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
2135 visibleVariant = savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1;
2136 showVariantInDOM(sessionId, visibleVariant);
2137 // showVariantInDOM hid the original (display:none); if we were still
2138 // anchored to the original's content, its boundingRect is now zero
2139 // and the bar snaps to (0,0). Re-point at the visible variant instead.
2140 const visEl = pickVariantContent(wrapper, visibleVariant);
2141 if (visEl) selectedElement = visEl;
2142 }
2143
2144 const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
2145 if (expected > 0) expectedVariants = expected;
2146
2147 if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
2148 state = 'CYCLING';
2149 hideShaderOverlay();
2150 updateBarContent('cycling');
2151 refreshParamsPanel();
2152 } else if (state === 'GENERATING') {
2153 updateBarContent('generating');
2154 }
2155 saveSession();
2156 queueCheckpoint(state === 'CYCLING' ? 'variants_ready' : 'variants_progress');
2157 updating = false;
2158 });
2159
2160 obs.observe(document.body, { childList: true, subtree: true });
2161 return obs;
2162 }
2163
2164 // ---------------------------------------------------------------------------
2165 // Bar scroll tracking
2166 // ---------------------------------------------------------------------------
2167
2168 function startScrollTracking() {
2169 function tick() {
2170 if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
2171 positionBar();
2172 showHighlight(selectedElement);
2173 if (tuneOpen) positionParamsPanel();
2174 }
2175 if (annotActive) positionAnnotOverlay(selectedElement);
2176 // Shader overlay (via debug P toggle or generation) is repositioned
2177 // by its own branch below; debug no longer has a separate overlay.
2178 if (shaderState) positionShaderOverlay();
2179 scrollRaf = requestAnimationFrame(tick);
2180 }
2181 scrollRaf = requestAnimationFrame(tick);
2182 }
2183
2184 function stopScrollTracking() {
2185 if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; }
2186 }
2187
2188 // ---------------------------------------------------------------------------
2189 // SSE (server→browser) + fetch POST (browser→server)
2190 // Zero-dependency replacement for WebSocket.
2191 // ---------------------------------------------------------------------------
2192
2193 let evtSource = null;
2194 let sseRetries = 0;
2195 const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
2196
2197 function connectSSE() {
2198 evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
2199
2200 evtSource.onopen = () => {
2201 sseRetries = 0; // reset on successful (re)connect
2202 };
2203
2204 evtSource.onmessage = (e) => {
2205 sseRetries = 0; // reset on any successful message
2206 let msg; try { msg = JSON.parse(e.data); } catch { return; }
2207 switch (msg.type) {
2208 case 'connected':
2209 hasProjectContext = !!msg.hasProjectContext;
2210 if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000);
2211 console.log('[impeccable] Live mode connected.');
2212 if (state === 'IDLE') state = 'PICKING';
2213 break;
2214 case 'done':
2215 // Variants already arrived via HMR → normal transition.
2216 if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
2217 if (state === 'GENERATING') {
2218 state = 'CYCLING';
2219 updateBarContent('cycling');
2220 refreshParamsPanel();
2221 }
2222 break;
2223 }
2224 // Variants are in source but not in the DOM yet. Common when the
2225 // picked element lived inside conditional render (closed modal,
2226 // hidden tab, a route the user navigated away from). The variant
2227 // MutationObserver stays armed and auto-transitions to CYCLING
2228 // the moment the wrapper actually mounts. Nudge the user toward
2229 // that path with a toast — better than the prior force-reload
2230 // which reset framework state and left the session stuck.
2231 setTimeout(() => {
2232 if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
2233 if (state !== 'GENERATING') return;
2234 showToast(
2235 "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
2236 15000,
2237 );
2238 }, 2000);
2239 break;
2240 case 'error':
2241 console.error('[impeccable] Error:', msg.message);
2242 showToast('Error: ' + msg.message, 5000);
2243 hideBar();
2244 state = 'PICKING';
2245 break;
2246 }
2247 };
2248
2249 evtSource.onerror = () => {
2250 sseRetries++;
2251 if (sseRetries <= SSE_MAX_RETRIES) {
2252 console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
2253 return; // EventSource auto-reconnects
2254 }
2255 // Server is gone. Clean up gracefully.
2256 console.log('[impeccable] Live server unreachable. Cleaning up UI.');
2257 evtSource.close();
2258 evtSource = null;
2259 handleServerLost();
2260 };
2261 }
2262
2263 /** Server died or became unreachable. Reset UI to a clean state. */
2264 function handleServerLost() {
2265 const recoveryState = currentSessionId ? state : 'IDLE';
2266 if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
2267 showToast('Live server disconnected. Session ended.', 5000);
2268 }
2269 hideBar();
2270 hideHighlight();
2271 hideShaderOverlay();
2272 hideAnnotOverlay();
2273 stopScrollTracking();
2274 if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
2275 stopScrollLock();
2276 // Preserve local session state on server loss. The durable journal is the
2277 // source of truth, but localStorage plus the variant wrapper lets the UI
2278 // resume after a helper restart or page reload instead of treating a
2279 // transient disconnect as an explicit discard.
2280 selectedElement = null;
2281 selectedAction = 'impeccable';
2282 state = recoveryState;
2283 if (currentSessionId) saveSession();
2284 }
2285
2286 function sendEvent(msg, opts) {
2287 msg.token = TOKEN;
2288 function handleFailure(err) {
2289 console.error('[impeccable] Failed to send event:', err);
2290 if (opts && opts.throwOnError) throw err;
2291 return null;
2292 }
2293 return fetch('http://localhost:' + PORT + '/events', {
2294 method: 'POST',
2295 headers: { 'Content-Type': 'application/json' },
2296 body: JSON.stringify(msg),
2297 }).then(res => {
2298 if (res.ok) return res;
2299 return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText));
2300 }).catch(handleFailure);
2301 }
2302
2303 function checkpointPayload(reason) {
2304 return {
2305 type: 'checkpoint',
2306 id: currentSessionId,
2307 revision: sessionState.nextCheckpointRevision(),
2308 owner: browserOwner,
2309 phase: String(state || '').toLowerCase(),
2310 reason,
2311 pageUrl: location.pathname,
2312 expectedVariants,
2313 arrivedVariants,
2314 visibleVariant,
2315 paramValues: { ...paramsCurrentValues },
2316 };
2317 }
2318
2319 function sendCheckpoint(reason) {
2320 if (!currentSessionId) return Promise.resolve(null);
2321 return sendEvent(checkpointPayload(reason)).catch(() => null);
2322 }
2323
2324 function queueCheckpoint(reason) {
2325 if (!currentSessionId) return;
2326 if (checkpointTimer) clearTimeout(checkpointTimer);
2327 checkpointTimer = setTimeout(() => {
2328 checkpointTimer = null;
2329 sendCheckpoint(reason);
2330 }, 120);
2331 }
2332
2333 // ---------------------------------------------------------------------------
2334 // Event handlers
2335 // ---------------------------------------------------------------------------
2336
2337 function handleMouseMove(e) {
2338 if (state !== 'PICKING' || !pickActive) return;
2339 const target = document.elementFromPoint(e.clientX, e.clientY);
2340 if (!target || !pickable(target) || target === hoveredElement) return;
2341 hoveredElement = target;
2342 showHighlight(target);
2343 }
2344
2345 function handleClick(e) {
2346 // Close action picker on any outside click
2347 if (pickerEl?.style.display !== 'none' && !own(e.target)) {
2348 hideActionPicker();
2349 }
2350 // Close Tune popover on outside click (anything outside panel + bar)
2351 if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
2352 closeTunePopover();
2353 }
2354 // In CONFIGURING: click outside the bar and selected element returns to PICKING
2355 if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
2356 hideBar();
2357 stopScrollTracking();
2358 hideAnnotOverlay();
2359 clearAnnotations();
2360 state = 'PICKING';
2361 hoveredElement = null;
2362 hideHighlight();
2363 return;
2364 }
2365 if (state !== 'PICKING' || !pickActive) return;
2366 if (own(e.target)) return;
2367 if (!hoveredElement || !pickable(hoveredElement)) return;
2368 e.preventDefault();
2369 e.stopPropagation();
2370 selectedElement = hoveredElement;
2371 state = 'CONFIGURING';
2372 showHighlight(selectedElement);
2373 clearAnnotations();
2374 showAnnotOverlay(selectedElement);
2375 showBar('configure');
2376 startScrollTracking();
2377 maybePrefetchPage();
2378 maybeWarnConditionalAncestor(selectedElement);
2379 }
2380
2381 /**
2382 * Surface a brief, non-blocking heads-up when the picked element lives
2383 * inside a container whose visibility is gated by ephemeral state — modals,
2384 * collapsible panels, popovers, off-screen tab panels. If HMR remounts the
2385 * parent during generation (Vite Fast Refresh, SvelteKit page reload), the
2386 * variants land in source but stay invisible until the user re-opens the
2387 * container. Telling the user upfront is much friendlier than the silent
2388 * timeout-then-toast that they'd otherwise hit.
2389 *
2390 * Heuristic, intentionally narrow — only fires for unambiguous cases so
2391 * we don't cry wolf on every nested element.
2392 */
2393 function maybeWarnConditionalAncestor(el) {
2394 let node = el?.parentElement;
2395 let depth = 0;
2396 while (node && depth < 12) {
2397 // 1. Active dialog / modal
2398 if (node.getAttribute && node.getAttribute('role') === 'dialog'
2399 && node.getAttribute('aria-modal') === 'true') {
2400 showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
2401 return;
2402 }
2403 // 2. Common Radix / shadcn / headless-ui open-state attribute
2404 if (node.dataset && node.dataset.state === 'open') {
2405 showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
2406 return;
2407 }
2408 // 3. Tab panel — only meaningful when the page also shows ANOTHER
2409 // tab as selected. A single tabpanel with no tablist is just a static
2410 // section in disguise and isn't conditional.
2411 if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
2412 const list = document.querySelector('[role="tablist"]');
2413 if (list) {
2414 const tabs = list.querySelectorAll('[role="tab"]');
2415 if (tabs.length > 1) {
2416 showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);
2417 return;
2418 }
2419 }
2420 }
2421 // 4. Collapsible: aria-expanded sibling. Look for the trigger button.
2422 if (node.id) {
2423 const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);
2424 if (trigger) {
2425 showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);
2426 return;
2427 }
2428 }
2429 node = node.parentElement;
2430 depth++;
2431 }
2432 }
2433
2434 // Fire a lightweight prefetch event the first time the user selects an
2435 // element on a given route. The agent uses this to Read the underlying file
2436 // 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't
2438 // re-fire.
2439 //
2440 // DISABLED: quick-Go workflows pay an extra harness round trip because
2441 // prefetch + generate arrive as two events instead of one. Re-enable with
2442 // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to
2443 // resurrect this. Server validator and skill dispatch remain in place so
2444 // flipping this flag is the only change needed.
2445 const PREFETCH_ENABLED = false;
2446 const prefetchedPaths = new Set();
2447 function maybePrefetchPage() {
2448 if (!PREFETCH_ENABLED) return;
2449 const path = location.pathname;
2450 if (prefetchedPaths.has(path)) return;
2451 prefetchedPaths.add(path);
2452 sendEvent({ type: 'prefetch', pageUrl: path });
2453 }
2454
2455 function handleKeyDown(e) {
2456 // When the annotation input is focused, let it handle its own keys.
2457 if (annotEditing && annotEditing.input && e.target === annotEditing.input) return;
2458 if (e.key === 'Escape') {
2459 e.preventDefault();
2460 if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; }
2461 if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; }
2462 if (state === 'CYCLING') { handleDiscard(); return; }
2463 if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt
2464 if (state === 'PICKING') {
2465 // Use togglePick so the "Pick" button in the global bar also flips
2466 // off, otherwise the bar stays lit while nothing else is active.
2467 if (pickActive) togglePick();
2468 else { hideHighlight(); state = 'IDLE'; }
2469 return;
2470 }
2471 }
2472
2473 // Arrow/Enter nav works in PICKING (hover) and CONFIGURING (selected, input empty)
2474 var navEl = (state === 'PICKING') ? hoveredElement : (state === 'CONFIGURING') ? selectedElement : null;
2475 if (navEl && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || (e.key === 'Enter' && state === 'PICKING'))) {
2476 let next = null;
2477 if (e.key === 'ArrowDown' && !e.shiftKey) {
2478 next = navEl.nextElementSibling;
2479 while (next && !pickable(next)) next = next.nextElementSibling;
2480 } else if (e.key === 'ArrowUp' && !e.shiftKey) {
2481 next = navEl.previousElementSibling;
2482 while (next && !pickable(next)) next = next.previousElementSibling;
2483 } else if (e.key === 'ArrowUp' && e.shiftKey) {
2484 next = navEl.parentElement;
2485 if (next && !pickable(next)) next = null;
2486 } else if (e.key === 'ArrowDown' && e.shiftKey) {
2487 next = navEl.firstElementChild;
2488 while (next && !pickable(next)) next = next.nextElementSibling;
2489 } else if (e.key === 'Enter') {
2490 e.preventDefault();
2491 selectedElement = hoveredElement;
2492 state = 'CONFIGURING';
2493 showHighlight(selectedElement);
2494 clearAnnotations();
2495 showAnnotOverlay(selectedElement);
2496 showBar('configure');
2497 startScrollTracking();
2498 return;
2499 }
2500 if (next) {
2501 e.preventDefault();
2502 if (state === 'PICKING') {
2503 hoveredElement = next;
2504 } else {
2505 // CONFIGURING: re-select the new element and refresh the bar
2506 selectedElement = next;
2507 clearAnnotations();
2508 showAnnotOverlay(next);
2509 showBar('configure');
2510 startScrollTracking();
2511 }
2512 showHighlight(next);
2513 next.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2514 }
2515 return;
2516 }
2517
2518 if (state === 'CYCLING') {
2519 if (e.key === 'ArrowLeft') { e.preventDefault(); cycleVariant(-1); }
2520 if (e.key === 'ArrowRight') { e.preventDefault(); cycleVariant(1); }
2521 if (e.key === 'Enter') { e.preventDefault(); handleAccept(); }
2522 }
2523 }
2524
2525 function handleGo() {
2526 if (!selectedElement || state !== 'CONFIGURING') return;
2527 const input = document.getElementById(PREFIX + '-input');
2528 const prompt = input ? input.value.trim() : '';
2529
2530 // Commit any pending pin edit BEFORE we snapshot annotations.
2531 if (annotEditing) finalizeEditingPin();
2532
2533 currentSessionId = id8();
2534 expectedVariants = selectedCount;
2535 arrivedVariants = 0;
2536 visibleVariant = 0;
2537
2538 // Flip to GENERATING immediately so the bar morphs without waiting on
2539 // capture + upload. The event is emitted from captureAndEmit() once the
2540 // screenshot is uploaded (or capture fails — we still emit, just without
2541 // screenshotPath).
2542 const elForCapture = selectedElement;
2543 const captureRect = elForCapture.getBoundingClientRect();
2544 const snapshot = {
2545 comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })),
2546 strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })),
2547 };
2548 const basePayload = {
2549 type: 'generate', id: currentSessionId,
2550 action: selectedAction,
2551 freeformPrompt: prompt || undefined,
2552 count: selectedCount,
2553 pageUrl: location.pathname,
2554 element: extractContext(elForCapture),
2555 };
2556 if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
2557 if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
2558
2559 // Hide the interactive overlay so it doesn't linger during generation.
2560 hideAnnotOverlay();
2561 clearAnnotations();
2562
2563 state = 'GENERATING';
2564 showBar('generating');
2565 saveSession();
2566 sendCheckpoint('generate_started');
2567 writeScrollY(window.scrollY);
2568 if (variantObserver) variantObserver.disconnect();
2569 variantObserver = startVariantObserver(currentSessionId);
2570 console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId });
2571 startScrollLock(currentSessionId);
2572
2573 captureAndEmit(elForCapture, basePayload, snapshot, captureRect);
2574 }
2575
2576 // ---------------------------------------------------------------------------
2577 // Screenshot capture + upload
2578 // ---------------------------------------------------------------------------
2579
2580 let msLoadPromise = null;
2581 function loadModernScreenshot() {
2582 if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot);
2583 if (msLoadPromise) return msLoadPromise;
2584 msLoadPromise = new Promise((resolve, reject) => {
2585 const s = document.createElement('script');
2586 s.src = 'http://localhost:' + PORT + '/modern-screenshot.js';
2587 s.onload = () => resolve(window.modernScreenshot);
2588 s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); };
2589 document.head.appendChild(s);
2590 });
2591 return msLoadPromise;
2592 }
2593
2594 // Collect @font-face rules from every stylesheet on the page. Cross-origin
2595 // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
2596 // access, so modern-screenshot can't embed them on its own — the resulting
2597 // SVG falls back to system fonts and text re-wraps + renders with different
2598 // weight. We fetch the raw CSS text (CORS-permitted for these providers),
2599 // extract @font-face blocks, inline the referenced font files as base64
2600 // data URIs (SVGs rasterized via canvas can't fetch external resources,
2601 // so URLs inside the SVG silently fail without this), and pass the result
2602 // to modern-screenshot as font.cssText.
2603 const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i;
2604 const FONT_MIME = {
2605 woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject',
2606 };
2607 function bufferToBase64(buf) {
2608 const bytes = new Uint8Array(buf);
2609 let binary = '';
2610 const CHUNK = 0x8000;
2611 for (let i = 0; i < bytes.length; i += CHUNK) {
2612 binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
2613 }
2614 return btoa(binary);
2615 }
2616 async function inlineFontUrls(cssText) {
2617 const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g;
2618 const urls = new Set();
2619 let m;
2620 while ((m = urlRe.exec(cssText))) {
2621 if (FONT_EXT_RE.test(m[2])) urls.add(m[2]);
2622 }
2623 const map = new Map();
2624 await Promise.all([...urls].map(async (url) => {
2625 try {
2626 const res = await fetch(url);
2627 if (!res.ok) return;
2628 const buf = await res.arrayBuffer();
2629 const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2';
2630 const mime = FONT_MIME[ext] || 'application/octet-stream';
2631 map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf));
2632 } catch { /* skip; fall through to URL */ }
2633 }));
2634 return cssText.replace(urlRe, (orig, q, url) => {
2635 const data = map.get(url);
2636 return data ? 'url(' + q + data + q + ')' : orig;
2637 });
2638 }
2639 async function collectFontCssText() {
2640 const chunks = [];
2641 const fontFaceRe = /@font-face\s*\{[^}]*\}/g;
2642 for (const sheet of document.styleSheets) {
2643 try {
2644 const rules = sheet.cssRules;
2645 for (const rule of rules) {
2646 if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) {
2647 chunks.push(rule.cssText);
2648 }
2649 }
2650 } catch {
2651 if (!sheet.href) continue;
2652 try {
2653 const res = await fetch(sheet.href);
2654 if (!res.ok) continue;
2655 const text = await res.text();
2656 let m2;
2657 while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]);
2658 } catch { /* ignore; capture is best-effort */ }
2659 }
2660 }
2661 if (chunks.length === 0) return '';
2662 return inlineFontUrls(chunks.join('\n'));
2663 }
2664
2665 // True if `s` is a computed color string that renders as nothing
2666 // (explicit `transparent`, or `rgba(...)` with alpha 0).
2667 function isTransparentColor(s) {
2668 if (!s) return true;
2669 if (s === 'transparent') return true;
2670 const m = /rgba?\(([^)]+)\)/.exec(s);
2671 if (!m) return false;
2672 const parts = m[1].split(',').map((p) => p.trim());
2673 if (parts.length === 4) return parseFloat(parts[3]) === 0;
2674 return false;
2675 }
2676
2677 // modern-screenshot force-sets `background-color: X !important` on the
2678 // cloned root whenever `backgroundColor` is passed, clobbering the
2679 // element's own background. So we only pass it when the element is
2680 // genuinely transparent (no own color, no own image) — in that case
2681 // we resolve up the DOM to the nearest opaque ancestor so the capture
2682 // sits on the page's real background instead of rendering black.
2683 function resolveCanvasBackground(el) {
2684 const own = getComputedStyle(el);
2685 if (!isTransparentColor(own.backgroundColor)) return null;
2686 if (own.backgroundImage && own.backgroundImage !== 'none') return null;
2687 let node = el.parentElement;
2688 while (node) {
2689 const cs = getComputedStyle(node);
2690 if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
2691 node = node.parentElement;
2692 }
2693 // The walk already passed through <body> and <html>; if they had been
2694 // opaque we would have returned. Falling through with the previous
2695 // `getComputedStyle(body).backgroundColor || …` chain is a trap: that
2696 // call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
2697 // never set its own bg, which is truthy and short-circuits the chain to
2698 // transparent-black — modern-screenshot then renders the capture on a
2699 // black canvas and the shader overlay flashes solid black during load.
2700 // The browser canvas defaults to white, so we do too.
2701 return '#ffffff';
2702 }
2703
2704 // Capture the element (with current annotations baked in) and return a PNG
2705 // Blob. Shared between the Go flow (uploads it to the server) and the
2706 // debug toggle (displays it as an overlay for side-by-side comparison).
2707 async function captureElementToBlob(el, snapshot, rect) {
2708 try { if (document.fonts?.ready) await document.fonts.ready; } catch {}
2709 const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
2710 let annotNode = null;
2711 let savedPosition = null;
2712 if (hasAnnotations) {
2713 const pos = getComputedStyle(el).position;
2714 if (pos === 'static') {
2715 savedPosition = el.style.position;
2716 el.style.position = 'relative';
2717 }
2718 annotNode = buildAnnotationsForCapture(rect, snapshot);
2719 el.appendChild(annotNode);
2720 }
2721 try {
2722 const ms = await loadModernScreenshot();
2723 const fontCssText = await collectFontCssText();
2724 const backgroundColor = resolveCanvasBackground(el);
2725 return await ms.domToBlob(el, {
2726 scale: Math.min(window.devicePixelRatio || 1, 2),
2727 font: fontCssText ? { cssText: fontCssText } : undefined,
2728 ...(backgroundColor ? { backgroundColor } : {}),
2729 });
2730 } finally {
2731 if (annotNode) annotNode.remove();
2732 if (savedPosition !== null) el.style.position = savedPosition;
2733 }
2734 }
2735
2736 async function captureAndEmit(el, basePayload, snapshot, rect) {
2737 let screenshotPath;
2738 let blob;
2739 try {
2740 blob = await captureElementToBlob(el, snapshot, rect);
2741 } catch (err) {
2742 console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
2743 }
2744 // Light up the shader overlay the moment capture is ready — no reason to
2745 // wait for the upload to complete before the user sees something alive.
2746 if (blob && state === 'GENERATING') {
2747 showShaderOverlay(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 the
2752 // three-distinct-directions brief.
2753 const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
2754 if (blob && hasAnnotations) {
2755 try {
2756 const 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 );
2761 if (uploadRes.ok) {
2762 const { path: p } = await uploadRes.json();
2763 screenshotPath = p;
2764 } else {
2765 console.warn('[impeccable] annotation upload failed:', uploadRes.status);
2766 }
2767 } catch (err) {
2768 console.warn('[impeccable] annotation upload failed:', err);
2769 }
2770 }
2771 sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload);
2772 }
2773
2774 // ---------------------------------------------------------------------------
2775 // Shader overlay — renders the captured screenshot as a WebGL texture and
2776 // runs an editorial "ink-wash" fragment shader over it during generation.
2777 // A single rolling band sweeps top-to-bottom, desaturating + tinting magenta
2778 // and leaving a soft trail. Makes the wait feel like a letterpress scan
2779 // instead of a dead spinner.
2780 // ---------------------------------------------------------------------------
2781
2782 const SHADER_VS = `attribute vec2 a_position;
2783attribute vec2 a_uv;
2784varying vec2 v_uv;
2785void main() {
2786 v_uv = a_uv;
2787 gl_Position = vec4(a_position, 0.0, 1.0);
2788}`;
2789
2790 const 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;
2796
2797// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
2798// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
2799// 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) {
2802 float above = smoothstep(-leadW, 0.0, d);
2803 float below = 1.0 - smoothstep(0.0, trailW, d);
2804 return above * below;
2805}
2806
2807void main() {
2808 vec2 uv = v_uv;
2809 // Roller sweeps top-to-bottom with small overshoot so each cycle enters
2810 // and exits the element cleanly.
2811 float phase = fract(u_time / 3.4);
2812 float y = phase * 1.25 - 0.12;
2813 float band = bandAt(uv.y - y, 0.05, 0.32);
2814
2815 // Halftone cell grid (fixed ~10 px pitch).
2816 float cellPx = 10.0;
2817 vec2 gridUv = uv * u_resolution / cellPx;
2818 vec2 cellId = floor(gridUv);
2819 vec2 cellUv = fract(gridUv) - 0.5;
2820 vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
2821 vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
2822 float luma = dot(cellImg, vec3(0.299, 0.587, 0.114));
2823 // Darker cells → bigger magenta dots (classic risograph halftone curve).
2824 float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56;
2825 float dotMask = smoothstep(radius + 0.06, radius, length(cellUv));
2826 vec3 paper = vec3(0.975, 0.965, 0.955);
2827 vec3 dotLayer = mix(paper, u_accent, dotMask);
2828
2829 // Blend the halftone layer in where the roller is passing; leave the
2830 // element pristine elsewhere.
2831 vec3 base = texture2D(u_texture, uv).rgb;
2832 gl_FragColor = vec4(mix(base, dotLayer, band), 1.0);
2833}`;
2834
2835 // Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350))
2836 const SHADER_ACCENT = [0.82, 0.16, 0.47];
2837 let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
2838
2839 function compileShader(gl, type, source) {
2840 const sh = gl.createShader(type);
2841 gl.shaderSource(sh, source);
2842 gl.compileShader(sh);
2843 if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
2844 const info = gl.getShaderInfoLog(sh);
2845 gl.deleteShader(sh);
2846 throw new Error('shader compile failed: ' + info);
2847 }
2848 return sh;
2849 }
2850
2851 function positionShaderOverlay() {
2852 if (!shaderState || !selectedElement) return;
2853 const r = selectedElement.getBoundingClientRect();
2854 Object.assign(shaderState.canvas.style, {
2855 top: r.top + 'px', left: r.left + 'px',
2856 width: r.width + 'px', height: r.height + 'px',
2857 });
2858 }
2859
2860 function hideShaderOverlay() {
2861 if (!shaderState) return;
2862 if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
2863 if (shaderState.canvas) shaderState.canvas.remove();
2864 const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
2865 try { lose?.loseContext(); } catch {}
2866 shaderState = null;
2867 }
2868
2869 async function showShaderOverlay(el, blob, rect) {
2870 hideShaderOverlay();
2871 if (!blob || !el) return;
2872 const canvas = document.createElement('canvas');
2873 canvas.id = PREFIX + '-shader';
2874 const dpr = Math.min(window.devicePixelRatio || 1, 2);
2875 canvas.width = Math.max(1, Math.floor(rect.width * dpr));
2876 canvas.height = Math.max(1, Math.floor(rect.height * dpr));
2877 Object.assign(canvas.style, {
2878 position: 'fixed',
2879 top: rect.top + 'px', left: rect.left + 'px',
2880 width: rect.width + 'px', height: rect.height + 'px',
2881 pointerEvents: 'none',
2882 zIndex: Z.bar - 1,
2883 });
2884 document.body.appendChild(canvas);
2885
2886 const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
2887 || canvas.getContext('experimental-webgl');
2888 if (!gl) {
2889 // WebGL unavailable — fall back to a plain <img> overlay so the user
2890 // still sees something meaningful during generation.
2891 canvas.remove();
2892 const img = document.createElement('img');
2893 img.src = URL.createObjectURL(blob);
2894 img.id = PREFIX + '-shader';
2895 // Copy positioning via cssText. Object.assign across CSSStyleDeclaration
2896 // throws in modern Chromium because the source's indexed properties
2897 // (style[0], [1], ...) are read-only and the engine forbids writing
2898 // them on the destination.
2899 img.style.cssText = canvas.style.cssText;
2900 img.style.outline = '2px dashed ' + C.brand;
2901 img.style.outlineOffset = '-2px';
2902 document.body.appendChild(img);
2903 shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
2904 return;
2905 }
2906
2907 let program, texture;
2908 try {
2909 const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS);
2910 const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS);
2911 program = gl.createProgram();
2912 gl.attachShader(program, vs);
2913 gl.attachShader(program, fs);
2914 gl.linkProgram(program);
2915 if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
2916 throw new Error('program link failed: ' + gl.getProgramInfoLog(program));
2917 }
2918 // Full-screen quad
2919 const buf = gl.createBuffer();
2920 gl.bindBuffer(gl.ARRAY_BUFFER, buf);
2921 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
2922 -1, -1, 0, 1,
2923 1, -1, 1, 1,
2924 -1, 1, 0, 0,
2925 -1, 1, 0, 0,
2926 1, -1, 1, 1,
2927 1, 1, 1, 0,
2928 ]), gl.STATIC_DRAW);
2929 const posLoc = gl.getAttribLocation(program, 'a_position');
2930 const uvLoc = gl.getAttribLocation(program, 'a_uv');
2931 gl.enableVertexAttribArray(posLoc);
2932 gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0);
2933 gl.enableVertexAttribArray(uvLoc);
2934 gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8);
2935 } catch (err) {
2936 console.warn('[impeccable] shader setup failed:', err);
2937 canvas.remove();
2938 return;
2939 }
2940
2941 // Upload the screenshot as a texture
2942 let bitmap;
2943 try {
2944 bitmap = await createImageBitmap(blob);
2945 } catch {
2946 // Safari fallback: go via a regular Image
2947 const imgUrl = URL.createObjectURL(blob);
2948 const img = new Image();
2949 img.src = imgUrl;
2950 await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
2951 bitmap = img;
2952 URL.revokeObjectURL(imgUrl);
2953 }
2954 texture = gl.createTexture();
2955 gl.bindTexture(gl.TEXTURE_2D, texture);
2956 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
2957 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
2958 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
2959 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
2960 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
2961 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap);
2962 if (bitmap.close) bitmap.close();
2963
2964 const uTime = gl.getUniformLocation(program, 'u_time');
2965 const uRes = gl.getUniformLocation(program, 'u_resolution');
2966 const uAccent = gl.getUniformLocation(program, 'u_accent');
2967 const uTex = gl.getUniformLocation(program, 'u_texture');
2968 const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
2969
2970 shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
2971 function frame() {
2972 if (!shaderState) return;
2973 const elapsed = (performance.now() - shaderState.startTime) / 1000;
2974 const t = shaderState.reduced ? 0.0 : elapsed;
2975 gl.viewport(0, 0, canvas.width, canvas.height);
2976 gl.useProgram(program);
2977 gl.activeTexture(gl.TEXTURE0);
2978 gl.bindTexture(gl.TEXTURE_2D, texture);
2979 gl.uniform1i(uTex, 0);
2980 gl.uniform1f(uTime, t);
2981 gl.uniform2f(uRes, canvas.width, canvas.height);
2982 gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]);
2983 gl.drawArrays(gl.TRIANGLES, 0, 6);
2984 shaderState.rafId = requestAnimationFrame(frame);
2985 }
2986 frame();
2987 }
2988
2989 function handleAccept() {
2990 if (!currentSessionId || arrivedVariants === 0) return;
2991 const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId);
2992 if (domVisibleVariant > 0) visibleVariant = domVisibleVariant;
2993 const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
2994 if (Object.keys(paramsCurrentValues).length > 0) {
2995 acceptPayload.paramValues = { ...paramsCurrentValues };
2996 }
2997 // The accepted variant is already the only visible child of the wrapper
2998 // (all other variants are display:none). HMR from the source rewrite will
2999 // replace the wrapper imminently. Don't eagerly replaceChild here — React
3000 // reconciliation races with our mutation and throws NotFoundError in Next
3001 // 16 / Turbopack. Schedule a fallback that runs the manual swap only if
3002 // HMR hasn't cleaned up by then (keeps static-server flows working).
3003 const acceptedSessionId = currentSessionId;
3004 const acceptedVariant = visibleVariant;
3005
3006 state = 'SAVING';
3007 updateBarContent('saving');
3008
3009 sendEvent(acceptPayload, { throwOnError: true })
3010 .then(() => {
3011 markSessionHandled();
3012 confirmAcceptAfterReceipt();
3013 })
3014 .catch(() => {
3015 state = 'CYCLING';
3016 updateBarContent('cycling');
3017 showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
3018 });
3019
3020 function confirmAcceptAfterReceipt() {
3021 state = 'CONFIRMED';
3022 updateBarContent('confirmed');
3023 scheduleAcceptCleanup();
3024 }
3025
3026 function scheduleAcceptCleanup() {
3027 setTimeout(function() {
3028 hideBar();
3029 hideHighlight();
3030 stopScrollTracking();
3031 if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
3032 stopScrollLock();
3033 clearScrollY();
3034 clearSession();
3035 selectedElement = null;
3036 currentSessionId = null;
3037 selectedAction = 'impeccable';
3038 state = 'PICKING';
3039 }, 1800);
3040
3041 // Static-server / no-HMR fallback: if the wrapper is still around 2s after
3042 // the cleanup above, swap it out manually. By now React has either moved
3043 // 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 variant
3045 // attribute keep matching until reload replaces it with the carbonize block.
3046 setTimeout(function() {
3047 const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
3048 if (!wrapper) return;
3049 const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
3050 if (accepted && accepted.firstElementChild) {
3051 const parent = wrapper.parentElement;
3052 if (!parent) return;
3053 accepted.style.display = 'contents';
3054 parent.replaceChild(accepted, wrapper);
3055 }
3056 }, 2000);
3057 }
3058 }
3059
3060 function handleDiscard() {
3061 if (!currentSessionId) return;
3062 sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true })
3063 .then(() => {
3064 markSessionHandled();
3065 cleanup();
3066 })
3067 .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000));
3068 }
3069
3070 // ---------------------------------------------------------------------------
3071 // Session persistence via live-browser-session.js
3072 // ---------------------------------------------------------------------------
3073 // Survives page reloads, browser close/reopen, HMR, and accidental refreshes.
3074
3075 function saveSession() {
3076 if (!currentSessionId) return;
3077 // NOTE: scrollY is stored under a separate key (writeScrollY). Storing
3078 // it here would overwrite the Go-time value every time state changes.
3079 sessionState.saveSession({
3080 id: currentSessionId,
3081 state,
3082 action: selectedAction,
3083 count: selectedCount,
3084 expected: expectedVariants,
3085 arrived: arrivedVariants,
3086 visible: visibleVariant,
3087 });
3088 }
3089
3090 function loadSession() {
3091 return sessionState.loadSession();
3092 }
3093
3094 function clearSession() {
3095 sessionState.clearSession();
3096 }
3097
3098 /** Mark session as handled (accepted/discarded). The agent will clean up
3099 * the source, but until it does the wrapper is still in the HTML. This
3100 * prevents resumeSession from picking it up again after reload. */
3101 function markSessionHandled() {
3102 if (!currentSessionId) return;
3103 sessionState.markHandled(currentSessionId);
3104 }
3105
3106 function isSessionHandled(id) {
3107 return sessionState.isHandled(id);
3108 }
3109
3110 function clearHandled() {
3111 sessionState.clearHandled();
3112 }
3113
3114 function cleanup() {
3115 // Hide the wrapper immediately so variants disappear. DON'T structurally
3116 // 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 the
3118 // reconciler later tries to remove a wrapper we already removed.
3119 // Schedule a 2s fallback that does the manual swap only if HMR hasn't
3120 // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
3121 const cleanupSessionId = currentSessionId;
3122 if (cleanupSessionId) {
3123 const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
3124 if (wrapper) wrapper.style.display = 'none';
3125 }
3126 setTimeout(function() {
3127 if (!cleanupSessionId) return;
3128 const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
3129 if (!wrapper) return;
3130 const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
3131 if (orig) {
3132 const content = orig.firstElementChild;
3133 if (content) {
3134 wrapper.parentElement.replaceChild(content, wrapper);
3135 return;
3136 }
3137 }
3138 wrapper.remove();
3139 }, 2000);
3140 hideBar();
3141 hideHighlight();
3142 stopScrollTracking();
3143 if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
3144 stopScrollLock();
3145 clearScrollY();
3146 clearSession();
3147 selectedElement = null;
3148 currentSessionId = null;
3149 selectedAction = 'impeccable';
3150 state = 'PICKING';
3151 }
3152
3153 // ---------------------------------------------------------------------------
3154 // Toast
3155 // ---------------------------------------------------------------------------
3156
3157 function showToast(message, duration) {
3158 if (toastEl) toastEl.remove();
3159 // Stack the toast above the global bar (which sits at bottom:14px) so
3160 // the two never overlap. Read the bar's actual rect — its height varies
3161 // with hover-expanded labels — and fall back to a sensible default
3162 // when the bar isn't mounted yet.
3163 const barRect = globalBarEl?.getBoundingClientRect();
3164 const barTopFromBottom = barRect && barRect.height > 0
3165 ? Math.max(16, window.innerHeight - barRect.top + 12)
3166 : 16;
3167 toastEl = el('div', {
3168 position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
3169 transform: 'translateX(-50%) translateY(8px)',
3170 background: C.ink, color: C.white,
3171 fontFamily: FONT, fontSize: '12px',
3172 padding: '8px 16px', borderRadius: '8px',
3173 zIndex: Z.toast, opacity: '0',
3174 transition: 'opacity 0.25s ' + EASE + ', transform 0.25s ' + EASE,
3175 pointerEvents: 'none', maxWidth: '420px', textAlign: 'center',
3176 });
3177 toastEl.id = PREFIX + '-toast';
3178 toastEl.textContent = message;
3179 document.body.appendChild(toastEl);
3180 requestAnimationFrame(() => {
3181 toastEl.style.opacity = '1';
3182 toastEl.style.transform = 'translateX(-50%) translateY(0)';
3183 });
3184 setTimeout(() => {
3185 if (toastEl) {
3186 toastEl.style.opacity = '0';
3187 toastEl.style.transform = 'translateX(-50%) translateY(8px)';
3188 setTimeout(() => { if (toastEl) { toastEl.remove(); toastEl = null; } }, 250);
3189 }
3190 }, duration);
3191 }
3192
3193 // ---------------------------------------------------------------------------
3194 // Init
3195 // ---------------------------------------------------------------------------
3196
3197 // Resume an active variant session after HMR/page reload.
3198 // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote
3199 // variants before HMR fired. Pick up where we left off.
3200 function resumeSession() {
3201 const wrapper = document.querySelector('[data-impeccable-variants]');
3202 if (!wrapper) { clearSession(); clearHandled(); return false; }
3203
3204 const sessionId = wrapper.dataset.impeccableVariants;
3205
3206 // Don't resume if this session was already accepted/discarded
3207 if (isSessionHandled(sessionId)) return false;
3208
3209 currentSessionId = sessionId;
3210 expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0');
3211 const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
3212 arrivedVariants = variants.length;
3213
3214 // Restore state from localStorage if available
3215 const saved = loadSession();
3216 if (saved && saved.id === sessionId) {
3217 visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0);
3218 if (saved.action) selectedAction = saved.action;
3219 if (saved.count) selectedCount = saved.count;
3220 } else {
3221 visibleVariant = arrivedVariants > 0 ? 1 : 0;
3222 }
3223
3224 // Find the visible variant's content element for highlight positioning.
3225 // Try the visible variant first, fall back to the original's content.
3226 const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
3227 const origEl = pickVariantContent(wrapper, 'original');
3228 selectedElement = visEl || origEl || wrapper.parentElement;
3229
3230 // Set display state BEFORE starting observer (avoid triggering it)
3231 if (visibleVariant > 0) showVariantInDOM(currentSessionId, visibleVariant);
3232
3233 state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
3234 showBar(state === 'CYCLING' ? 'cycling' : 'generating');
3235 startScrollTracking();
3236 // Build the params panel for the restored visible variant. Previously
3237 // this was missed on page-reload resume: showVariantInDOM above fires
3238 // refreshParamsPanel, but state was still IDLE at that moment so it
3239 // hid. Now that state is CYCLING, re-fire.
3240 if (state === 'CYCLING') refreshParamsPanel();
3241 saveSession();
3242 queueCheckpoint('browser_resumed');
3243
3244 // Start observing for more variants AFTER initial setup
3245 if (variantObserver) variantObserver.disconnect();
3246 variantObserver = startVariantObserver(currentSessionId);
3247
3248 // Hold the target at its saved viewport top through any subsequent
3249 // HMR patches, variant inserts, or cycle swaps.
3250 startScrollLock(currentSessionId, readScrollY());
3251
3252 // If we reloaded mid-generation (Bun's HTML HMR destroys the shader
3253 // canvas), re-capture the original's content and restart the shader so
3254 // the wait doesn't go dead.
3255 if (state === 'GENERATING' && origEl) {
3256 (async () => {
3257 try {
3258 const rect = origEl.getBoundingClientRect();
3259 if (rect.width === 0 || rect.height === 0) return;
3260 const blob = await captureElementToBlob(origEl, null, rect);
3261 if (blob && state === 'GENERATING') {
3262 showShaderOverlay(origEl, blob, rect);
3263 }
3264 } catch (err) {
3265 console.warn('[impeccable] shader resume failed:', err);
3266 }
3267 })();
3268 }
3269 return true;
3270 }
3271
3272 // ---------------------------------------------------------------------------
3273 // Global bar (always visible at bottom)
3274 // ---------------------------------------------------------------------------
3275
3276 let globalBarEl = null;
3277 let detectActive = false;
3278 let pickActive = true;
3279 let detectCount = 0;
3280 let detectScriptLoaded = false;
3281
3282 // Theme-aware color palette for the global bar. We detect the page's
3283 // ambient background and invert — dark bar on light pages, light bar on
3284 // dark pages. This keeps the bar from fighting with the host design.
3285 function detectPageTheme() {
3286 try {
3287 // Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
3288 // 'dark' to preview the opposite palette without actually changing the
3289 // page bg. Used for screenshots and theme QA.
3290 const override = localStorage.getItem('impeccable-dev-theme');
3291 if (override === 'light' || override === 'dark') return override;
3292
3293 // Walk body → html, taking the first opaque background. The browser's
3294 // default body / html background is `rgba(0, 0, 0, 0)`, which a naive
3295 // regex would read as black and mislabel a perfectly white page as
3296 // 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).
3298 function readOpaque(el) {
3299 if (!el) return null;
3300 const bg = getComputedStyle(el).backgroundColor;
3301 const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
3302 if (!m) return null;
3303 const alpha = m[4] == null ? 1 : parseFloat(m[4]);
3304 if (alpha < 0.5) return null; // transparent / nearly transparent → skip
3305 return [+m[1], +m[2], +m[3]];
3306 }
3307
3308 const 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 it
3311 // via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
3312 // us catch that case.
3313 if (!rgb) {
3314 return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
3315 }
3316 const [r, g, b] = rgb;
3317 // Perceptual luminance (Rec. 709)
3318 const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
3319 return L > 0.55 ? 'light' : 'dark';
3320 } catch { return 'light'; }
3321 }
3322
3323 function barPaletteForTheme(theme) {
3324 if (theme === 'dark') {
3325 // Light bar on dark page
3326 return {
3327 surface: 'oklch(98% 0 0 / 0.92)',
3328 surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
3329 hairline: 'oklch(70% 0 0 / 0.35)',
3330 text: 'oklch(15% 0 0)',
3331 textDim: 'oklch(45% 0 0)',
3332 accent: 'oklch(60% 0.25 350)',
3333 accentSoft: 'oklch(60% 0.25 350 / 0.18)',
3334 mark: 'oklch(98% 0 0)', // logo mark fill
3335 markText: 'oklch(15% 0 0)', // logo "/" color
3336 exitHover: 'oklch(85% 0 0 / 0.5)',
3337 };
3338 }
3339 // Dark bar on light page. Bar is a warm charcoal, logo slab is much
3340 // deeper so the rounded-right shape reads as a clear sculpted mark.
3341 return {
3342 surface: 'oklch(26% 0 0 / 0.94)',
3343 surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
3344 hairline: 'oklch(42% 0 0 / 0.5)',
3345 text: 'oklch(96% 0 0)',
3346 textDim: 'oklch(72% 0 0)',
3347 accent: 'oklch(72% 0.22 350)',
3348 accentSoft: 'oklch(72% 0.22 350 / 0.22)',
3349 mark: 'oklch(8% 0 0)',
3350 markText: 'oklch(96% 0 0)',
3351 exitHover: 'oklch(36% 0 0 / 0.6)',
3352 };
3353 }
3354
3355 // Impeccable logo mark — matches the site-header SVG (rounded square + "/").
3356 function brandMarkSvg(fill, ink, size = 18) {
3357 return `<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 }
3362
3363 function initGlobalBar() {
3364 const theme = detectPageTheme();
3365 const P = barPaletteForTheme(theme);
3366
3367 // Custom focus-visible for bar buttons. Browser default is a heavy
3368 // blue ring that looks jarring on the dark capsule. Replace with a
3369 // soft accent-tinted inner ring that respects the bar's palette.
3370 if (!document.getElementById(PREFIX + '-bar-focus-style')) {
3371 const s = document.createElement('style');
3372 s.id = PREFIX + '-bar-focus-style';
3373 s.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 '}';
3379 document.head.appendChild(s);
3380 }
3381
3382 globalBarEl = el('div', {
3383 position: 'fixed', bottom: '14px', left: '50%',
3384 transform: 'translateX(-50%) translateY(20px)',
3385 zIndex: Z.bar + 5,
3386 display: 'flex', alignItems: 'stretch',
3387 gap: '2px',
3388 background: P.surface,
3389 backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
3390 border: '1px solid ' + P.hairline,
3391 borderRadius: '10px',
3392 boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)',
3393 fontFamily: FONT, fontSize: '12px', lineHeight: '1',
3394 opacity: '0',
3395 overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
3396 transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
3397 });
3398 globalBarEl.id = PREFIX + '-global-bar';
3399 globalBarEl.dataset.theme = theme;
3400
3401 // Brand mark — fills bar height on the left. Left side inherits the bar's
3402 // rounded corner via overflow:hidden; right side is a clean hard edge since
3403 // the near-black/charcoal contrast does the shape-defining work.
3404 const brand = el('span', {
3405 display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
3406 alignSelf: 'stretch',
3407 padding: '0 12px 0 14px',
3408 background: P.mark,
3409 color: P.markText,
3410 fontFamily: 'system-ui, -apple-system, sans-serif',
3411 fontWeight: '500',
3412 fontSize: '18px', lineHeight: '1',
3413 });
3414 brand.textContent = '/';
3415 brand.title = 'Impeccable';
3416 globalBarEl.appendChild(brand);
3417
3418 // Inner wrapper: holds the toggles with normal bar padding.
3419 const inner = el('div', {
3420 display: 'flex', alignItems: 'center',
3421 padding: '4px 5px', gap: '2px',
3422 });
3423 inner.id = PREFIX + '-global-bar-inner';
3424 globalBarEl.appendChild(inner);
3425
3426 // --- button factory: icon-only at rest, label slides in on hover/active ---
3427 function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
3428 const b = el('button', {
3429 position: 'relative',
3430 display: 'inline-flex', alignItems: 'center',
3431 padding: '6px 8px', borderRadius: '7px',
3432 border: 'none', background: 'transparent',
3433 color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
3434 cursor: 'pointer',
3435 transition: 'background 0.15s ease, color 0.15s ease',
3436 whiteSpace: 'nowrap', overflow: 'hidden',
3437 });
3438 b.id = id;
3439 b.title = ariaLabel || label || '';
3440 b.setAttribute('aria-label', ariaLabel || label || '');
3441 b.innerHTML = svg + (label
3442 ? `<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 : '');
3444 const labelEl = b.querySelector('.icon-btn-label');
3445 const expand = () => {
3446 if (!labelEl) return;
3447 labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px';
3448 };
3449 const collapse = () => {
3450 if (!labelEl || b.dataset.active === 'true') return;
3451 labelEl.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 moving
3455 // the mouse between adjacent buttons doesn't trigger per-button width
3456 // thrashing — the whole bar grows once and shrinks once.
3457 b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
3458 b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
3459 b.addEventListener('click', onClick);
3460 b._expandLabel = expand;
3461 b._collapseLabel = collapse;
3462 return b;
3463 }
3464
3465 // Pick toggle — starts active (primary intent when entering live mode).
3466 const pickBtn = makeIconBtn({
3467 id: PREFIX + '-pick-toggle',
3468 svg: '<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>',
3469 label: 'Pick',
3470 ariaLabel: 'Pick element',
3471 onClick: () => togglePick(),
3472 });
3473 pickBtn.style.background = P.accentSoft;
3474 pickBtn.style.color = P.accent;
3475 pickBtn.dataset.active = 'true';
3476 pickBtn._expandLabel();
3477 inner.appendChild(pickBtn);
3478
3479 // Detect toggle
3480 const detectBtn = makeIconBtn({
3481 id: PREFIX + '-detect-toggle',
3482 svg: '<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>',
3483 label: 'Detect',
3484 ariaLabel: 'Detect anti-patterns',
3485 onClick: () => toggleDetect(),
3486 });
3487 const detectBadge = el('span', {
3488 fontSize: '10px', fontWeight: '600',
3489 padding: '0px 5px', borderRadius: '7px', lineHeight: '16px',
3490 background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)',
3491 display: 'none', fontFamily: MONO, marginLeft: '4px',
3492 });
3493 detectBadge.id = PREFIX + '-detect-badge';
3494 detectBtn.appendChild(detectBadge);
3495 inner.appendChild(detectBtn);
3496
3497 // DESIGN.md panel toggle — quartet of color squares as the mark.
3498 const designBtn = makeIconBtn({
3499 id: PREFIX + '-design-toggle',
3500 svg: `<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>`,
3506 label: 'DESIGN.md',
3507 ariaLabel: 'Toggle DESIGN.md panel',
3508 labelFont: MONO,
3509 onClick: () => toggleDesignPanel(),
3510 });
3511 inner.appendChild(designBtn);
3512
3513 // Thin divider before the exit button
3514 const divider = el('span', {
3515 width: '1px', height: '18px',
3516 background: P.hairline,
3517 margin: '0 4px 0 2px',
3518 });
3519 inner.appendChild(divider);
3520
3521 // Exit × on the right — intentionally subtle (textDim at rest, text on
3522 // hover) so it sits behind the active toggles in visual hierarchy.
3523 //
3524 // Explicit padding + box-sizing here is load-bearing: a host page like
3525 // `button { padding: 0.5rem 1rem; }` (very common in resets) would
3526 // otherwise inflate this 24x24 button into 56x40 and push the SVG out
3527 // of the visible bar — the X stays invisible even though the styles in
3528 // DevTools look fine. Every other chrome button sets padding inline;
3529 // this one needed it too.
3530 const exitBtn = el('button', {
3531 display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
3532 padding: '0', boxSizing: 'border-box',
3533 width: '24px', height: '24px', borderRadius: '6px',
3534 border: 'none', background: 'transparent',
3535 color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
3536 cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
3537 });
3538 exitBtn.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>';
3539 exitBtn.title = 'Exit live mode';
3540 exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });
3541 exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
3542 exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
3543 inner.appendChild(exitBtn);
3544
3545 // Bar-level hover: expand every toggle's label at once; collapse on leave.
3546 // Buttons with dataset.active="true" ignore collapse (their label stays).
3547 const toggles = [pickBtn, detectBtn, designBtn];
3548 globalBarEl.addEventListener('mouseenter', () => {
3549 toggles.forEach((t) => t._expandLabel && t._expandLabel());
3550 });
3551 globalBarEl.addEventListener('mouseleave', () => {
3552 toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
3553 });
3554
3555 document.body.appendChild(globalBarEl);
3556 defangOutsideHandlers(globalBarEl);
3557
3558 requestAnimationFrame(() => {
3559 globalBarEl.style.opacity = '1';
3560 globalBarEl.style.transform = 'translateX(-50%) translateY(0)';
3561 });
3562
3563 // Listen for detection results AND ready signal
3564 window.addEventListener('message', onDetectMessage);
3565 }
3566
3567 function updateGlobalBarState() {
3568 const detectToggle = document.getElementById(PREFIX + '-detect-toggle');
3569 const detectBadge = document.getElementById(PREFIX + '-detect-badge');
3570 const pickToggle = document.getElementById(PREFIX + '-pick-toggle');
3571 const designToggle = document.getElementById(PREFIX + '-design-toggle');
3572 const theme = globalBarEl?.dataset.theme || 'light';
3573 const P = barPaletteForTheme(theme);
3574
3575 // Sync one toggle's active state, colors, and slide-label visibility.
3576 function sync(btn, active) {
3577 if (!btn) return;
3578 btn.style.background = active ? P.accentSoft : 'transparent';
3579 btn.style.color = active ? P.accent : P.textDim;
3580 btn.dataset.active = active ? 'true' : 'false';
3581 if (active && btn._expandLabel) btn._expandLabel();
3582 else if (!active && btn._collapseLabel) btn._collapseLabel();
3583 }
3584 sync(pickToggle, pickActive);
3585 sync(detectToggle, detectActive);
3586 sync(designToggle, designState.open);
3587
3588 // 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.
3591 if (globalBarEl && globalBarEl.matches(':hover')) {
3592 [pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
3593 }
3594
3595 if (detectBadge) {
3596 detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
3597 detectBadge.textContent = detectCount;
3598 }
3599
3600 // When pick is active, make detect overlays click-through so the picker works
3601 document.querySelectorAll('.impeccable-overlay').forEach(o => {
3602 o.style.pointerEvents = pickActive ? 'none' : '';
3603 });
3604 }
3605
3606 let detectReady = false; // true once detect script posts 'impeccable-ready'
3607 let detectPendingScan = false; // scan requested before script was ready
3608
3609 function toggleDetect() {
3610 detectActive = !detectActive;
3611 updateGlobalBarState();
3612
3613 if (detectActive) {
3614 if (!detectScriptLoaded) {
3615 detectPendingScan = true;
3616 loadDetectScript();
3617 } else if (detectReady) {
3618 window.postMessage({ source: 'impeccable-command', action: 'scan' }, '*');
3619 } else {
3620 detectPendingScan = true;
3621 }
3622 } else {
3623 window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
3624 detectCount = 0;
3625 updateGlobalBarState();
3626 }
3627 }
3628
3629 function togglePick() {
3630 pickActive = !pickActive;
3631 updateGlobalBarState();
3632
3633 if (!pickActive) {
3634 // Disabling pick clears any in-flight selection and UI: highlight,
3635 // contextual bar, selectedElement. Otherwise a stale selection sits
3636 // on screen with no obvious way to dismiss.
3637 hideHighlight();
3638 hideBar();
3639 hideActionPicker();
3640 selectedElement = null;
3641 if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE';
3642 } else {
3643 if (state === 'IDLE') state = 'PICKING';
3644 }
3645 }
3646
3647 function loadDetectScript() {
3648 if (detectScriptLoaded) return;
3649 detectScriptLoaded = true;
3650 const s = document.createElement('script');
3651 s.src = 'http://localhost:' + PORT + '/detect.js';
3652 s.dataset.impeccableExtension = 'true';
3653 document.head.appendChild(s);
3654 }
3655
3656 function onDetectMessage(e) {
3657 if (!e.data || typeof e.data.source !== 'string') return;
3658 // Detection script is loaded and ready
3659 if (e.data.source === 'impeccable-ready') {
3660 detectReady = true;
3661 if (detectPendingScan && detectActive) {
3662 detectPendingScan = false;
3663 window.postMessage({ source: 'impeccable-command', action: 'scan' }, '*');
3664 }
3665 }
3666 // Scan results arrived
3667 if (e.data.source === 'impeccable-results') {
3668 detectCount = e.data.count || 0;
3669 updateGlobalBarState();
3670 }
3671 }
3672
3673 /** Full teardown: remove all UI, disconnect SSE, clean up. */
3674 function teardown() {
3675 cleanup();
3676 hideBar();
3677 if (globalBarEl) {
3678 globalBarEl.style.transform = 'translateY(100%)';
3679 setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300);
3680 }
3681 if (highlightEl) { highlightEl.remove(); highlightEl = null; }
3682 if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
3683 if (barEl) { barEl.remove(); barEl = null; }
3684 if (pickerEl) { pickerEl.remove(); pickerEl = null; }
3685 if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
3686 if (evtSource) { evtSource.close(); evtSource = null; }
3687 document.removeEventListener('mousemove', handleMouseMove, true);
3688 document.removeEventListener('click', handleClick, true);
3689 document.removeEventListener('keydown', handleKeyDown, true);
3690 window.removeEventListener('message', onDetectMessage);
3691 // Remove detection overlays
3692 window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
3693 state = 'IDLE';
3694 window.__IMPECCABLE_LIVE_INIT__ = false;
3695 console.log('[impeccable] Live mode exited.');
3696 }
3697
3698 // ---------------------------------------------------------------------------
3699 // Design System Panel — visualizes the project's .impeccable/design.json sidecar
3700 // ---------------------------------------------------------------------------
3701
3702 const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
3703 const DESIGN_PANEL_WIDTH = 440;
3704
3705 let designHost = null;
3706 let designShadow = null;
3707 let designState = {
3708 open: false,
3709 tab: 'visual', // 'visual' | 'raw'
3710 parsed: null, // parseDesignMd output (frontmatter + body sections)
3711 sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative)
3712 hasMd: false,
3713 hasSidecar: false,
3714 present: null, // true/false once fetch resolves
3715 raw: null, // raw DESIGN.md for the raw tab
3716 mdNewerThanJson: false, // stale-hint flag
3717 loading: false,
3718 error: null,
3719 collapsed: { // narrative-section accordion state
3720 rules: true, dosdonts: true, overview: true,
3721 },
3722 };
3723
3724 function loadDesignPrefs() {
3725 // `open` is intentionally NOT persisted — the panel always starts closed
3726 // so live mode doesn't auto-slide a big panel over the page on startup.
3727 try {
3728 const raw = localStorage.getItem(DESIGN_PREFS_KEY);
3729 if (!raw) return;
3730 const prefs = JSON.parse(raw);
3731 if (prefs.tab === 'visual' || prefs.tab === 'raw') designState.tab = prefs.tab;
3732 if (prefs.collapsed && typeof prefs.collapsed === 'object') {
3733 Object.assign(designState.collapsed, prefs.collapsed);
3734 }
3735 } catch { /* ignore */ }
3736 }
3737
3738 function saveDesignPrefs() {
3739 try {
3740 localStorage.setItem(DESIGN_PREFS_KEY, JSON.stringify({
3741 tab: designState.tab,
3742 collapsed: designState.collapsed,
3743 }));
3744 } catch { /* ignore */ }
3745 }
3746
3747 function initDesignPanel() {
3748 designHost = document.createElement('div');
3749 designHost.id = PREFIX + '-design-host';
3750 Object.assign(designHost.style, {
3751 position: 'fixed', top: '0', left: '0',
3752 width: '0', height: '0',
3753 zIndex: String(Z.bar + 10),
3754 pointerEvents: 'none',
3755 });
3756 designShadow = designHost.attachShadow({ mode: 'open' });
3757
3758 const style = document.createElement('style');
3759 // Theme-match the bar: dark chrome on light pages, light chrome on dark pages.
3760 const theme = detectPageTheme();
3761 style.textContent = designPanelCss(barPaletteForTheme(theme));
3762 designShadow.appendChild(style);
3763
3764 const root = document.createElement('div');
3765 root.className = 'root';
3766 designShadow.appendChild(root);
3767
3768 document.body.appendChild(designHost);
3769 // The host is pointer-events: none; the panel inside the shadow DOM
3770 // manages its own auto/none. Events bubble through the shadow boundary,
3771 // so attaching here silences host-page outside-interaction handlers
3772 // without touching the host's click-through behavior.
3773 defangOutsideHandlers(designHost, { setPointerEvents: false });
3774
3775 loadDesignPrefs();
3776 renderDesignChrome();
3777 if (designState.open) {
3778 fetchDesignSystem();
3779 }
3780 }
3781
3782 // Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
3783 // a viewer of the project's design system, not an Impeccable surface.
3784 const DP = {
3785 canvas: 'oklch(94% 0 0)', // panel background
3786 tile: 'oklch(98.5% 0 0)', // card-on-canvas
3787 tileAlt: 'oklch(96% 0 0)', // subtler tile for inner surfaces
3788 ink: 'oklch(15% 0 0)',
3789 ink2: 'oklch(35% 0 0)',
3790 meta: 'oklch(55% 0 0)',
3791 hairline: 'oklch(88% 0 0)',
3792 hairlineSoft: 'oklch(92% 0 0)',
3793 amber: 'oklch(70% 0.13 65)', // stale-hint accent
3794 amberBg: 'oklch(95% 0.05 80)',
3795 };
3796
3797 function designPanelCss(BP) {
3798 // BP = bar palette (theme-aware, matches the global bar).
3799 // DP = internal content palette (neutral, so tiles render colors true).
3800 return `
3801 :host, .root { all: initial; }
3802 .root {
3803 font-family: ${FONT};
3804 color: ${DP.ink};
3805 pointer-events: none;
3806 }
3807 .root * { box-sizing: border-box; }
3808 button { font: inherit; color: inherit; }
3809
3810 /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */
3811 .panel {
3812 position: fixed; top: 12px; bottom: 72px; right: 12px;
3813 width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px);
3814 background: ${BP.surface};
3815 border: 1px solid ${BP.hairline};
3816 border-radius: 14px;
3817 backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
3818 box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08);
3819 display: flex; flex-direction: column;
3820 transform: translateX(calc(100% + 24px));
3821 opacity: 0;
3822 transition: transform 0.35s ${EASE}, opacity 0.25s ${EASE};
3823 pointer-events: none;
3824 overflow: hidden;
3825 }
3826 .panel[data-open="true"] { transform: translateX(0); opacity: 1; pointer-events: auto; }
3827
3828 .panel-header {
3829 display: flex; align-items: center; gap: 10px;
3830 padding: 10px 10px 10px 14px;
3831 background: transparent;
3832 border-bottom: 1px solid ${BP.hairline};
3833 }
3834 .panel-title {
3835 flex: 1; min-width: 0;
3836 font-family: ${MONO};
3837 font-size: 11.5px; font-weight: 600;
3838 letter-spacing: 0.02em;
3839 color: ${BP.text};
3840 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
3841 }
3842 .panel-close {
3843 border: none; background: transparent; color: ${BP.textDim};
3844 width: 26px; height: 26px; border-radius: 7px;
3845 display: inline-flex; align-items: center; justify-content: center;
3846 cursor: pointer; transition: background 0.15s ease, color 0.15s ease;
3847 }
3848 .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; }
3849
3850 .tabs {
3851 display: inline-flex; padding: 2px;
3852 background: ${BP.hairline};
3853 border-radius: 7px;
3854 gap: 2px;
3855 }
3856 .tab {
3857 border: none; background: transparent;
3858 padding: 4px 10px; border-radius: 5px;
3859 font-family: ${MONO};
3860 font-size: 10px; font-weight: 600; letter-spacing: 0.08em;
3861 text-transform: uppercase;
3862 color: ${BP.textDim}; cursor: pointer;
3863 transition: background 0.15s ease, color 0.15s ease;
3864 }
3865 .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; }
3866
3867 .panel-body {
3868 flex: 1; overflow-y: auto;
3869 padding: 12px 12px 20px;
3870 background: ${DP.canvas};
3871 scrollbar-width: thin;
3872 scrollbar-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; }
3876
3877 /* --- States --- */
3878 .empty, .loading, .error {
3879 margin: 16px 4px;
3880 padding: 28px 20px; text-align: center;
3881 background: ${DP.tile}; border-radius: 14px;
3882 color: ${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); }
3887
3888 /* --- Stale hint --- */
3889 .stale {
3890 display: flex; align-items: center; gap: 8px;
3891 margin: 8px 4px 12px;
3892 padding: 8px 12px;
3893 background: ${DP.amberBg};
3894 border-radius: 10px;
3895 font-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; }
3900
3901 /* --- Parsed-md fallback banner --- */
3902 .parsed-md-cta {
3903 margin: 8px 4px 14px;
3904 padding: 14px 16px;
3905 background: ${DP.tile};
3906 border: 1px dashed ${DP.hairline};
3907 border-radius: 12px;
3908 font-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}; }
3912
3913 /* --- Tile primitives --- */
3914 .tile {
3915 position: relative;
3916 background: ${DP.tile};
3917 border-radius: 16px;
3918 padding: 16px;
3919 margin: 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 {
3924 display: flex; align-items: baseline; justify-content: space-between;
3925 gap: 10px;
3926 font-family: ${MONO};
3927 font-size: 10px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase;
3928 color: ${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; }
3931
3932 /* --- Color tile --- */
3933 .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; }
3934 .c-tile:hover { transform: translateY(-1px); }
3935 .c-hero {
3936 height: 72px; border-radius: 10px; margin-top: 10px;
3937 box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.05);
3938 }
3939 .c-ramp {
3940 display: flex; gap: 0; height: 14px; border-radius: 4px; overflow: hidden;
3941 margin-top: 8px;
3942 box-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}; }
3946
3947 /* --- Type tile --- */
3948 .t-tile { }
3949 .t-specimen {
3950 margin: 4px 0 6px;
3951 color: ${DP.ink};
3952 line-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}; }
3956
3957 /* --- Shadow tile --- */
3958 .s-tile { }
3959 .s-surface {
3960 height: 60px; margin: 8px 2px 10px;
3961 background: ${DP.tile};
3962 border-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; }
3966
3967 /* --- 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}; }
3973
3974 /* --- Component tile (hosts live primitives) --- */
3975 .cmp-tile { }
3976 .cmp-stage {
3977 margin: 12px -4px 0;
3978 padding: 18px 16px 10px;
3979 border-top: 1px solid ${DP.hairlineSoft};
3980 display: flex; flex-direction: column; align-items: center; justify-content: center;
3981 gap: 14px;
3982 min-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}; }
3987
3988 /* --- Collapsible --- */
3989 .coll {
3990 margin: 0 4px 8px;
3991 background: ${DP.tile};
3992 border-radius: 12px;
3993 overflow: hidden;
3994 }
3995 .coll-head {
3996 display: flex; align-items: center; gap: 10px;
3997 width: 100%;
3998 padding: 12px 14px;
3999 background: transparent; border: none;
4000 cursor: pointer; text-align: left;
4001 font-family: ${FONT}; font-size: 12.5px; font-weight: 600; color: ${DP.ink};
4002 transition: background 0.12s ease;
4003 }
4004 .coll-head:hover { background: ${DP.tileAlt}; }
4005 .coll-chev {
4006 width: 12px; height: 12px; flex-shrink: 0;
4007 color: ${DP.meta};
4008 transition: 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; }
4014
4015 .rule-card {
4016 padding: 10px 0;
4017 border-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; }
4023
4024 .coll .dos { display: grid; gap: 0; margin-top: 2px; }
4025 .coll .do, .coll .dont {
4026 position: relative;
4027 padding: 8px 0 8px 22px;
4028 font-size: 11.5px; line-height: 1.5; color: ${DP.ink2};
4029 border-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 {
4035 content: ''; position: absolute; left: 4px; top: 13px;
4036 width: 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); }
4040
4041 .coll .overview-body {
4042 font-size: 12px; line-height: 1.55; color: ${DP.ink2};
4043 }
4044 .coll .overview-body .north-star {
4045 display: block; font-family: ${FONT}; font-style: italic;
4046 font-size: 15px; line-height: 1.3; color: ${DP.ink};
4047 margin-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; }
4052
4053 /* --- 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 }
4072
4073 function renderDesignChrome() {
4074 const root = designShadow.querySelector('.root');
4075 root.innerHTML = '';
4076
4077 // (Panel toggle lives in the global bar — no floating FAB.)
4078 // Panel
4079 const panel = document.createElement('aside');
4080 panel.className = 'panel';
4081 panel.setAttribute('data-open', designState.open ? 'true' : 'false');
4082 panel.appendChild(buildDesignHeader());
4083 const body = document.createElement('div');
4084 body.className = 'panel-body';
4085 body.id = 'panel-body';
4086 panel.appendChild(body);
4087 root.appendChild(panel);
4088
4089 renderDesignBody();
4090 }
4091
4092 function buildDesignHeader() {
4093 const header = document.createElement('div');
4094 header.className = 'panel-header';
4095
4096 const title = document.createElement('div');
4097 title.className = 'panel-title';
4098 title.textContent = 'DESIGN.md';
4099 header.appendChild(title);
4100
4101 const tabs = document.createElement('div');
4102 tabs.className = 'tabs';
4103 for (const t of [['visual', 'Visual'], ['raw', 'Raw']]) {
4104 const btn = document.createElement('button');
4105 btn.className = 'tab';
4106 btn.textContent = t[1];
4107 btn.setAttribute('data-active', designState.tab === t[0] ? 'true' : 'false');
4108 btn.addEventListener('click', () => {
4109 if (designState.tab === t[0]) return;
4110 designState.tab = t[0];
4111 saveDesignPrefs();
4112 renderDesignChrome();
4113 if (t[0] === 'raw' && designState.raw === null && !designState.loading) {
4114 fetchDesignSystem(); // raw is part of the same fetch pair
4115 }
4116 });
4117 tabs.appendChild(btn);
4118 }
4119 header.appendChild(tabs);
4120
4121 const close = document.createElement('button');
4122 close.className = 'panel-close';
4123 close.innerHTML = '✕';
4124 close.setAttribute('aria-label', 'Close panel');
4125 close.addEventListener('click', toggleDesignPanel);
4126 header.appendChild(close);
4127
4128 return header;
4129 }
4130
4131 function toggleDesignPanel() {
4132 designState.open = !designState.open;
4133 renderDesignChrome();
4134 updateGlobalBarState();
4135 if (designState.open && designState.present === null && !designState.loading) {
4136 fetchDesignSystem();
4137 }
4138 }
4139
4140 async function fetchDesignSystem() {
4141 designState.loading = true;
4142 designState.error = null;
4143 renderDesignBody();
4144 try {
4145 const [jsonRes, rawRes] = await Promise.all([
4146 fetch(`http://localhost:${PORT}/design-system.json?token=${TOKEN}`, { cache: 'no-store' }),
4147 fetch(`http://localhost:${PORT}/design-system/raw?token=${TOKEN}`, { cache: 'no-store' }),
4148 ]);
4149 const jsonData = await jsonRes.json();
4150 designState.present = jsonData.present === true;
4151 designState.parsed = jsonData.parsed || null;
4152 designState.sidecar = jsonData.sidecar || null;
4153 designState.hasMd = !!jsonData.hasMd;
4154 designState.hasSidecar = !!jsonData.hasSidecar;
4155 designState.mdNewerThanJson = !!jsonData.mdNewerThanJson;
4156 designState.raw = designState.present && rawRes.ok ? await rawRes.text() : null;
4157 designState.error = jsonData.parseError || jsonData.sidecarError || null;
4158 } catch (err) {
4159 designState.error = err?.message || 'Failed to load design system.';
4160 } finally {
4161 designState.loading = false;
4162 renderDesignChrome(); // refresh title from data
4163 }
4164 }
4165
4166 function renderDesignBody() {
4167 const body = designShadow.querySelector('#panel-body');
4168 if (!body) return;
4169 body.innerHTML = '';
4170
4171 if (designState.loading) {
4172 body.appendChild(msgDiv('loading', 'Loading design system…'));
4173 return;
4174 }
4175 if (designState.error) {
4176 body.appendChild(msgDiv('error', designState.error));
4177 return;
4178 }
4179 if (designState.present === false) {
4180 const empty = document.createElement('div');
4181 empty.className = 'empty';
4182 empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>/impeccable document</code> in your terminal, then re-open this panel.`;
4183 body.appendChild(empty);
4184 return;
4185 }
4186
4187 if (designState.tab === 'raw') {
4188 renderRawTab(body, designState.raw || '');
4189 return;
4190 }
4191
4192 // Visual tab — single unified render path.
4193 if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
4194 if (designState.hasMd && !designState.hasSidecar) {
4195 body.appendChild(renderParsedMdCta());
4196 }
4197 renderDesignVisual(body, designState.parsed, designState.sidecar);
4198 }
4199
4200 function msgDiv(cls, text) {
4201 const d = document.createElement('div');
4202 d.className = cls;
4203 d.textContent = text;
4204 return d;
4205 }
4206
4207 function renderStaleHint() {
4208 const box = document.createElement('div');
4209 box.className = 'stale';
4210 box.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 `;
4214 return box;
4215 }
4216
4217 function renderParsedMdCta() {
4218 const box = document.createElement('div');
4219 box.className = 'parsed-md-cta';
4220 box.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.`;
4221 return box;
4222 }
4223
4224 // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 ---
4225
4226 function renderDesignVisual(body, parsed, sidecar) {
4227 const frontmatter = parsed?.frontmatter || {};
4228 const extensions = sidecar?.extensions || {};
4229 const proseColors = parsed?.colors || null;
4230
4231 const colors = buildColorModels(frontmatter.colors, extensions.colorMeta, proseColors);
4232 if (colors.length) renderColorTiles(body, colors);
4233
4234 const types = buildTypographyModels(frontmatter.typography, extensions.typographyMeta);
4235 if (types.length) renderTypeTiles(body, types);
4236
4237 const radii = buildRadiiModels(frontmatter.rounded);
4238 if (radii.length) renderRadiiTile(body, radii);
4239
4240 if (extensions.shadows?.length) renderShadowTiles(body, extensions.shadows);
4241
4242 const components = sidecar?.components || [];
4243 if (components.length) renderComponentTiles(body, components);
4244
4245 // Narrative: sidecar wins if present (richer, agent-curated). Otherwise
4246 // synthesize from prose sections.
4247 const narrative = sidecar?.narrative || synthesizeNarrative(parsed);
4248 if (narrative.rules?.length) body.appendChild(renderRulesCollapsible(narrative.rules));
4249 if ((narrative.dos?.length || narrative.donts?.length)) body.appendChild(renderDosDontsCollapsible(narrative));
4250 if (narrative.overview || narrative.northStar || narrative.keyCharacteristics?.length) {
4251 body.appendChild(renderOverviewCollapsible(narrative));
4252 }
4253
4254 if (body.childElementCount === 0) {
4255 body.appendChild(msgDiv('empty', 'No design system data available.'));
4256 }
4257 }
4258
4259 // Frontmatter primitives + sidecar colorMeta → tile-ready color models.
4260 // A matching prose bullet (when the slug sits in the bullet text) supplies
4261 // description as a last-resort fallback.
4262 function buildColorModels(fmColors, colorMeta, proseColors) {
4263 if (!fmColors) return [];
4264 const meta = colorMeta || {};
4265 return Object.entries(fmColors).map(([key, value]) => {
4266 const m = meta[key] || {};
4267 return {
4268 role: m.role || humanizeKey(key),
4269 name: m.displayName || humanizeKey(key),
4270 value: value,
4271 canonical: m.canonical || null,
4272 description: m.description || findProseDescription(proseColors, key, m.displayName),
4273 tonalRamp: m.tonalRamp || null,
4274 };
4275 });
4276 }
4277
4278 function buildTypographyModels(fmTypography, typographyMeta) {
4279 if (!fmTypography) return [];
4280 const meta = typographyMeta || {};
4281 return Object.entries(fmTypography).map(([key, spec]) => {
4282 const m = meta[key] || {};
4283 const { family, fallback } = splitFontFamily(spec?.fontFamily);
4284 return {
4285 role: key,
4286 name: m.displayName || humanizeKey(key),
4287 family,
4288 fallback,
4289 weight: spec?.fontWeight ?? 400,
4290 // fontStyle isn't in Stitch's frontmatter schema; the sidecar carries
4291 // it when a role is rendered in italic (e.g. display italic).
4292 style: m.style || 'normal',
4293 sampleSize: spec?.fontSize || '1rem',
4294 lineHeight: spec?.lineHeight != null ? String(spec.lineHeight) : '',
4295 letterSpacing: spec?.letterSpacing,
4296 purpose: m.purpose,
4297 };
4298 });
4299 }
4300
4301 function buildRadiiModels(fmRounded) {
4302 if (!fmRounded) return [];
4303 return Object.entries(fmRounded).map(([name, value]) => ({ name, value }));
4304 }
4305
4306 function splitFontFamily(stack) {
4307 if (!stack || typeof stack !== 'string') return { family: '', fallback: '' };
4308 const parts = stack.split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, ''));
4309 return { family: parts[0] || '', fallback: parts.slice(1).join(', ') };
4310 }
4311
4312 function humanizeKey(k) {
4313 return String(k || '').replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
4314 }
4315
4316 function findProseDescription(proseColors, key, displayName) {
4317 if (!proseColors || !proseColors.groups) return null;
4318 const needles = [key, displayName].filter(Boolean).map((s) => s.toLowerCase());
4319 for (const g of proseColors.groups) {
4320 for (const c of g.colors || []) {
4321 const hay = String(c.name || '').toLowerCase();
4322 if (hay && needles.some((n) => hay.includes(n) || n.includes(hay))) {
4323 return c.description || null;
4324 }
4325 }
4326 }
4327 return null;
4328 }
4329
4330 function synthesizeNarrative(parsed) {
4331 if (!parsed) return {};
4332 const md = parsed;
4333 return {
4334 northStar: md.overview?.creativeNorthStar,
4335 overview: (md.overview?.philosophy || []).join(' '),
4336 keyCharacteristics: md.overview?.keyCharacteristics || [],
4337 rules: [
4338 ...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
4339 ...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
4340 ...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
4341 ],
4342 dos: md.dosDonts?.dos || [],
4343 donts: md.dosDonts?.donts || [],
4344 };
4345 }
4346
4347 function renderColorTiles(body, colors) {
4348 for (const c of colors) {
4349 const tile = document.createElement('div');
4350 tile.className = 'tile c-tile';
4351 tile.title = 'Click to copy';
4352 tile.addEventListener('click', () => copyToClipboard(c.value));
4353
4354 const meta = document.createElement('div');
4355 meta.className = 'tile-meta';
4356 meta.innerHTML = `<span class="name">${escapeHtml(c.name || c.role || 'Color')}</span><span>${escapeHtml(c.value || '')}</span>`;
4357 tile.appendChild(meta);
4358
4359 const hero = document.createElement('div');
4360 hero.className = 'c-hero';
4361 hero.style.background = c.value;
4362 tile.appendChild(hero);
4363
4364 const ramp = synthesizeRamp(c);
4365 if (ramp.length) {
4366 const r = document.createElement('div');
4367 r.className = 'c-ramp';
4368 r.innerHTML = ramp.map((v) => `<span style="background:${cssSafe(v)}"></span>`).join('');
4369 tile.appendChild(r);
4370 }
4371
4372 if (c.description) {
4373 const d = document.createElement('div');
4374 d.className = 'c-desc';
4375 d.textContent = c.description;
4376 tile.appendChild(d);
4377 }
4378 body.appendChild(tile);
4379 }
4380 }
4381
4382 function synthesizeRamp(c) {
4383 if (c.tonalRamp?.length) return c.tonalRamp;
4384 // If base value is OKLCH, synthesize an 8-step ramp across lightness.
4385 const m = typeof c.value === 'string' && c.value.match(/^oklch\(\s*([\d.]+)%\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.]+))?\s*\)$/i);
4386 if (!m) return [];
4387 const [, , chroma, hue] = m;
4388 const steps = [20, 32, 44, 56, 68, 80, 90, 96];
4389 return steps.map((l) => `oklch(${l}% ${chroma} ${hue})`);
4390 }
4391
4392 function renderTypeTiles(body, types) {
4393 for (const t of types) {
4394 const tile = document.createElement('div');
4395 tile.className = 'tile t-tile';
4396
4397 const meta = document.createElement('div');
4398 meta.className = 'tile-meta';
4399 meta.innerHTML = `<span>${escapeHtml(t.role || '')}</span><span>${escapeHtml(t.weight || '')} ${escapeHtml(t.style === 'italic' ? 'italic' : '')}</span>`;
4400 tile.appendChild(meta);
4401
4402 const specimen = document.createElement('div');
4403 specimen.className = 't-specimen';
4404 specimen.textContent = 'Aa';
4405 specimen.style.fontFamily = fontStack(t);
4406 specimen.style.fontWeight = String(t.weight || 400);
4407 specimen.style.fontStyle = t.style || 'normal';
4408 specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
4409 specimen.style.letterSpacing = 'normal';
4410 specimen.style.textTransform = 'none';
4411 tile.appendChild(specimen);
4412
4413 // The system's actual sample size for this role, shown as small mono meta below.
4414 if (t.sampleSize) {
4415 const scale = document.createElement('div');
4416 scale.style.cssText = 'font-family:' + MONO + '; font-size: 10px; color:' + DP.meta + '; margin-top: 2px;';
4417 scale.textContent = t.sampleSize;
4418 tile.appendChild(scale);
4419 }
4420
4421 const family = document.createElement('div');
4422 family.className = 't-family';
4423 family.textContent = t.family || t.name || '';
4424 tile.appendChild(family);
4425
4426 if (t.purpose) {
4427 const p = document.createElement('div');
4428 p.className = 't-purpose';
4429 p.textContent = t.purpose;
4430 tile.appendChild(p);
4431 }
4432 body.appendChild(tile);
4433 }
4434 }
4435
4436 function fontStack(t) {
4437 const fam = t.family || '';
4438 const fb = t.fallback || '';
4439 if (fam && /[,\s]/.test(fam) && !fam.includes("'") && !fam.includes('"')) {
4440 return `"${fam}", ${fb}`;
4441 }
4442 return fam && fb ? `"${fam}", ${fb}` : (fam || fb);
4443 }
4444
4445 function renderRadiiTile(body, radii) {
4446 const tile = document.createElement('div');
4447 tile.className = 'tile';
4448 const meta = document.createElement('div');
4449 meta.className = 'tile-meta';
4450 meta.innerHTML = `<span class="name">Corner Radii</span><span>${radii.length}</span>`;
4451 tile.appendChild(meta);
4452
4453 const strip = document.createElement('div');
4454 strip.className = 'r-strip';
4455 for (const r of radii) {
4456 const item = document.createElement('div');
4457 item.className = 'r-item';
4458 const s = document.createElement('div');
4459 s.className = 'r-sample';
4460 s.style.borderRadius = r.value || '0';
4461 item.appendChild(s);
4462 const lbl = document.createElement('div');
4463 lbl.className = 'r-label';
4464 lbl.textContent = r.name || '';
4465 item.appendChild(lbl);
4466 const val = document.createElement('div');
4467 val.className = 'r-val';
4468 val.textContent = r.value || '';
4469 item.appendChild(val);
4470 strip.appendChild(item);
4471 }
4472 tile.appendChild(strip);
4473 body.appendChild(tile);
4474 }
4475
4476 function renderShadowTiles(body, shadows) {
4477 for (const sh of shadows) {
4478 const tile = document.createElement('div');
4479 tile.className = 'tile s-tile';
4480
4481 const meta = document.createElement('div');
4482 meta.className = 'tile-meta';
4483 meta.innerHTML = `<span class="name">${escapeHtml(sh.name || 'Shadow')}</span><span>Elevation</span>`;
4484 tile.appendChild(meta);
4485
4486 const surface = document.createElement('div');
4487 surface.className = 's-surface';
4488 surface.style.boxShadow = sh.value || 'none';
4489 tile.appendChild(surface);
4490
4491 const val = document.createElement('div');
4492 val.className = 's-value';
4493 val.textContent = sh.value || '';
4494 tile.appendChild(val);
4495
4496 if (sh.purpose) {
4497 const p = document.createElement('div');
4498 p.className = 's-purpose';
4499 p.textContent = sh.purpose;
4500 tile.appendChild(p);
4501 }
4502 body.appendChild(tile);
4503 }
4504 }
4505
4506 function renderComponentTiles(body, components) {
4507 // Group consecutive components that share a kind into one tile. This avoids
4508 // a pile of one-component tiles (e.g., three button variants = three tiles)
4509 // and reads more like a proper category.
4510 const groups = groupByKind(components);
4511
4512 for (const group of groups) {
4513 const tile = document.createElement('div');
4514 tile.className = 'tile cmp-tile';
4515
4516 const meta = document.createElement('div');
4517 meta.className = 'tile-meta';
4518 const groupTitle = group.length === 1
4519 ? (group[0].name || group[0].kind || 'Component')
4520 : titleForKind(group[0].kind, group.length);
4521 meta.innerHTML = `<span class="name">${escapeHtml(groupTitle)}</span><span class="cmp-kind">${escapeHtml(group[0].kind || '')}</span>`;
4522 tile.appendChild(meta);
4523
4524 for (const c of group) {
4525 const stage = document.createElement('div');
4526 stage.className = 'cmp-stage';
4527
4528 // Render the component in its own shadow root so its CSS can't bleed.
4529 const host = document.createElement('div');
4530 const sub = host.attachShadow({ mode: 'open' });
4531 const style = document.createElement('style');
4532 style.textContent = c.css || '';
4533 sub.appendChild(style);
4534 const container = document.createElement('div');
4535 container.innerHTML = c.html || '';
4536 sub.appendChild(container);
4537 stage.appendChild(host);
4538
4539 // Show component name as a sublabel only when the tile groups >1 item,
4540 // or when the component's display name differs from its kind.
4541 const showSublabel = group.length > 1;
4542 if (showSublabel) {
4543 const lbl = document.createElement('div');
4544 lbl.className = 'cmp-sublabel';
4545 lbl.textContent = c.name || '';
4546 stage.appendChild(lbl);
4547 }
4548 tile.appendChild(stage);
4549 }
4550
4551 // Single shared description if all items carry the same one; otherwise
4552 // skip — per-item descriptions clutter a grouped tile.
4553 if (group.length === 1 && group[0].description) {
4554 const d = document.createElement('div');
4555 d.className = 'c-desc';
4556 d.textContent = group[0].description;
4557 tile.appendChild(d);
4558 }
4559 body.appendChild(tile);
4560 }
4561 }
4562
4563 function groupByKind(components) {
4564 const groups = [];
4565 for (const c of components) {
4566 const last = groups[groups.length - 1];
4567 if (last && last[0].kind && c.kind === last[0].kind) {
4568 last.push(c);
4569 } else {
4570 groups.push([c]);
4571 }
4572 }
4573 return groups;
4574 }
4575
4576 function titleForKind(kind, count) {
4577 const labels = {
4578 button: 'Buttons',
4579 input: 'Inputs',
4580 nav: 'Navigation',
4581 chip: 'Chips',
4582 card: 'Cards',
4583 custom: 'Components',
4584 };
4585 return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components');
4586 }
4587
4588 // --- Collapsibles ---------------------------------------------------------
4589
4590 function buildCollapsible(key, label, count) {
4591 const wrap = document.createElement('div');
4592 wrap.className = 'coll';
4593 wrap.setAttribute('data-open', designState.collapsed[key] ? 'false' : 'true');
4594
4595 const head = document.createElement('button');
4596 head.className = 'coll-head';
4597 head.innerHTML = `
4598 <svg class="coll-chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2.5L8 6 4 9.5"/></svg>
4599 <span>${escapeHtml(label)}</span>
4600 ${count != null ? `<span class="coll-count">${escapeHtml(String(count))}</span>` : ''}
4601 `;
4602 head.addEventListener('click', () => {
4603 designState.collapsed[key] = !designState.collapsed[key];
4604 saveDesignPrefs();
4605 renderDesignBody();
4606 });
4607 wrap.appendChild(head);
4608
4609 const body = document.createElement('div');
4610 body.className = 'coll-body';
4611 wrap.appendChild(body);
4612 return { wrap, body };
4613 }
4614
4615 function renderRulesCollapsible(rules) {
4616 const { wrap, body } = buildCollapsible('rules', 'Named Rules', rules.length);
4617 for (const r of rules) {
4618 const card = document.createElement('div');
4619 card.className = 'rule-card';
4620 const name = document.createElement('div');
4621 name.className = 'name';
4622 name.innerHTML = `${escapeHtml(r.name)}${r.section ? `<span class="section">${escapeHtml(r.section)}</span>` : ''}`;
4623 card.appendChild(name);
4624 const b = document.createElement('div');
4625 b.className = 'body';
4626 b.textContent = r.body || '';
4627 card.appendChild(b);
4628 body.appendChild(card);
4629 }
4630 return wrap;
4631 }
4632
4633 function renderDosDontsCollapsible(n) {
4634 const total = (n.dos?.length || 0) + (n.donts?.length || 0);
4635 const { wrap, body } = buildCollapsible('dosdonts', "Do's and Don'ts", total);
4636 const grid = document.createElement('div');
4637 grid.className = 'dos';
4638 for (const d of n.dos || []) {
4639 const el = document.createElement('div');
4640 el.className = 'do';
4641 el.innerHTML = inlineMd(d);
4642 grid.appendChild(el);
4643 }
4644 for (const d of n.donts || []) {
4645 const el = document.createElement('div');
4646 el.className = 'dont';
4647 el.innerHTML = inlineMd(d);
4648 grid.appendChild(el);
4649 }
4650 body.appendChild(grid);
4651 return wrap;
4652 }
4653
4654 function renderOverviewCollapsible(n) {
4655 const { wrap, body } = buildCollapsible('overview', 'Overview', null);
4656 const ov = document.createElement('div');
4657 ov.className = 'overview-body';
4658 if (n.northStar) {
4659 const star = document.createElement('span');
4660 star.className = 'north-star';
4661 star.textContent = '“' + n.northStar + '”';
4662 ov.appendChild(star);
4663 }
4664 if (n.overview) {
4665 const p = document.createElement('p');
4666 p.innerHTML = inlineMd(n.overview);
4667 ov.appendChild(p);
4668 }
4669 if (n.keyCharacteristics?.length) {
4670 const ul = document.createElement('ul');
4671 ul.innerHTML = n.keyCharacteristics.map((k) => `<li>${inlineMd(k)}</li>`).join('');
4672 ov.appendChild(ul);
4673 }
4674 body.appendChild(ov);
4675 return wrap;
4676 }
4677
4678 function cssSafe(v) {
4679 // Strip anything outside valid CSS value chars to prevent injection via
4680 // .impeccable/design.json values rendered into inline style strings.
4681 return String(v).replace(/[<>"'`\n]/g, '');
4682 }
4683
4684 // --- Raw tab: minimal markdown renderer (subset) --------------------------
4685
4686 function renderRawTab(body, md) {
4687 const wrap = document.createElement('div');
4688 wrap.className = 'md';
4689 wrap.innerHTML = renderMarkdown(md);
4690 body.appendChild(wrap);
4691 }
4692
4693 function renderMarkdown(md) {
4694 const lines = md.split(/\r?\n/);
4695 const out = [];
4696 let i = 0;
4697 let inCode = false;
4698 let codeBuf = [];
4699 let paraBuf = [];
4700 let listBuf = []; // array of { indent, html }
4701 let listType = null; // 'ul' | 'ol'
4702
4703 const flushPara = () => {
4704 if (paraBuf.length) {
4705 out.push(`<p>${inlineMd(paraBuf.join(' '))}</p>`);
4706 paraBuf = [];
4707 }
4708 };
4709 const flushList = () => {
4710 if (listBuf.length) {
4711 out.push(buildListHtml(listBuf, listType));
4712 listBuf = [];
4713 listType = null;
4714 }
4715 };
4716 const flushAll = () => { flushPara(); flushList(); };
4717
4718 for (; i < lines.length; i++) {
4719 const line = lines[i];
4720
4721 // Code fence
4722 const fence = line.match(/^```(\w*)\s*$/);
4723 if (fence) {
4724 if (!inCode) { flushAll(); inCode = true; codeBuf = []; }
4725 else {
4726 out.push(`<pre><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`);
4727 inCode = false;
4728 }
4729 continue;
4730 }
4731 if (inCode) { codeBuf.push(line); continue; }
4732
4733 if (line.trim() === '') { flushAll(); continue; }
4734
4735 const hr = line.match(/^\s*(?:---+|\*\*\*+)\s*$/);
4736 if (hr) { flushAll(); out.push('<hr />'); continue; }
4737
4738 const heading = line.match(/^(#{1,4})\s+(.+)$/);
4739 if (heading) {
4740 flushAll();
4741 const lvl = heading[1].length;
4742 out.push(`<h${lvl}>${inlineMd(heading[2])}</h${lvl}>`);
4743 continue;
4744 }
4745
4746 const bullet = line.match(/^(\s*)([-*])\s+(.+)$/);
4747 const ordered = line.match(/^(\s*)(\d+)\.\s+(.+)$/);
4748 if (bullet || ordered) {
4749 flushPara();
4750 const m = bullet || ordered;
4751 const indent = Math.floor(m[1].length / 2);
4752 const t = bullet ? 'ul' : 'ol';
4753 if (listType && listType !== t) flushList();
4754 listType = t;
4755 listBuf.push({ indent, html: inlineMd(m[3]) });
4756 continue;
4757 }
4758
4759 paraBuf.push(line);
4760 }
4761 flushAll();
4762 if (inCode && codeBuf.length) {
4763 out.push(`<pre><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`);
4764 }
4765 return out.join('\n');
4766 }
4767
4768 function buildListHtml(items, type) {
4769 // Nest by indent (one level deep is plenty for DESIGN.md).
4770 let html = `<${type}>`;
4771 let lastIndent = 0;
4772 for (const it of items) {
4773 if (it.indent > lastIndent) html += `<${type}>`;
4774 else if (it.indent < lastIndent) html += `</${type}>`.repeat(lastIndent - it.indent);
4775 html += `<li>${it.html}</li>`;
4776 lastIndent = it.indent;
4777 }
4778 html += `</${type}>`.repeat(lastIndent + 1);
4779 return html;
4780 }
4781
4782 function inlineMd(text) {
4783 // Order matters: escape first, then re-inject tags.
4784 let s = escapeHtml(text);
4785 // Code spans
4786 s = s.replace(/`([^`]+)`/g, (_, code) => `<code>${code}</code>`);
4787 // Links [text](url)
4788 s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `<a href="${u}" target="_blank" rel="noopener noreferrer">${t}</a>`);
4789 // Bold
4790 s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
4791 // Italic (only single *…*, skip if inside bold already handled)
4792 s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>');
4793 return s;
4794 }
4795
4796 function highlightBold(text) {
4797 return inlineMd(text);
4798 }
4799
4800 function escapeHtml(s) {
4801 return String(s)
4802 .replace(/&/g, '&')
4803 .replace(/</g, '<')
4804 .replace(/>/g, '>')
4805 .replace(/"/g, '"')
4806 .replace(/'/g, ''');
4807 }
4808
4809 function copyToClipboard(text) {
4810 if (!text) return;
4811 try {
4812 navigator.clipboard.writeText(text);
4813 showToast('Copied: ' + text);
4814 } catch { /* ignore */ }
4815 }
4816
4817 // ---------------------------------------------------------------------------
4818 // Init
4819 // ---------------------------------------------------------------------------
4820
4821 function init() {
4822 try { history.scrollRestoration = 'manual'; } catch {}
4823 initHighlight();
4824 initAnnotOverlay();
4825 initBar();
4826 initActionPicker();
4827 initParamsPanel();
4828 initGlobalBar();
4829 initDesignPanel();
4830 document.addEventListener('mousemove', handleMouseMove, true);
4831 document.addEventListener('click', handleClick, true);
4832 document.addEventListener('keydown', handleKeyDown, true);
4833 connectSSE();
4834
4835 // Check for an active session to resume (variant wrapper already in DOM after HMR)
4836 if (!resumeSession()) {
4837 console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
4838 // SvelteKit (and any framework that hydrates after HTML parse) may add
4839 // the variant wrapper AFTER init runs. Watch for it and retry resume
4840 // once it appears. Disconnect on first hit.
4841 const scout = new MutationObserver(() => {
4842 const wrapper = document.querySelector('[data-impeccable-variants]');
4843 if (!wrapper) return;
4844 scout.disconnect();
4845 if (resumeSession()) {
4846 console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
4847 }
4848 });
4849 scout.observe(document.body, { childList: true, subtree: true });
4850 } else {
4851 console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
4852 }
4853 }
4854
4855 if (document.readyState === 'loading') {
4856 document.addEventListener('DOMContentLoaded', init);
4857 } else {
4858 init();
4859 }
4860})();