返回 AiToEarn
live-browser.js
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 kinpaku (gold) is pinned to the site's neo-kinpaku tokens
34 // (see site/styles/kinpaku-tokens.css) so Accept / knobs / cycle-dots /
35 // the selection outline / the comment tag all match the site's accent,
36 // not a washed theme-adjusted one. These mirror the kit's picker
37 // colors in site/styles/kinpaku-kit.css; keep them in sync by hand.
38 const C = {
39 brand: 'oklch(84% 0.19 80.46)', // kinpaku gold
40 brandHov: 'oklch(86% 0.07 84)', // kinpaku-pale (hover lift)
41 brandSoft: 'oklch(84% 0.19 80.46 / 0.18)', // kinpaku-dim
42 ink: 'oklch(4% 0.004 95)', // lacquer-deep
43 ash: 'oklch(55% 0.018 82)', // warm muted text
44 paper: 'oklch(98% 0.005 95 / 0.92)', // light overlay on user pages
45 paperSolid:'oklch(98% 0.005 95)',
46 mist: 'oklch(90% 0.008 82 / 0.6)', // light hairline
47 white: 'oklch(99% 0 0)',
48 };
49 // Picker bar chrome - mirrors .live-demo-gbar / .live-demo-ctx in kinpaku-kit.css.
50 // Quiet neutral elevation: no gold halo ring (gold is reserved for the brand
51 // mark and the active control, not the container outline).
52 const PICKER_SHADOW =
53 '0 16px 36px -12px oklch(0% 0 0 / 0.6)';
54 const FONT = 'system-ui, -apple-system, sans-serif';
55 const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';
56 // z-index: detect overlays use 99999, so our UI must be above them
57 const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
58 const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
59 const PREFIX = 'impeccable-live';
60 const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000;
61 const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
62 prefix: PREFIX,
63 storage: localStorage,
64 idFactory: () => crypto.randomUUID().replace(/-/g, '').slice(0, 8),
65 });
66 if (!sessionState) {
67 console.error('[impeccable] live-browser-session.js was not loaded. Live mode cannot start safely.');
68 window.__IMPECCABLE_LIVE_INIT__ = false;
69 return;
70 }
71 const HIGHLIGHT_TRANSITION =
72 'top 140ms ' + EASE +
73 ', left 140ms ' + EASE +
74 ', width 140ms ' + EASE +
75 ', height 140ms ' + EASE +
76 ', opacity 150ms ease';
77 const TOOLTIP_TRANSITION =
78 'top 140ms ' + EASE + ', left 140ms ' + EASE + ', opacity 150ms ease';
79
80 const SKIP_TAGS = new Set([
81 'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
82 ]);
83
84 // SVG icons stack above each chip label. All strokes use currentColor so the
85 // icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
86 // 1.5 stroke - visually consistent with the Foundation grid on the homepage.
87 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"';
88 const ICONS = {
89 impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
90 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>`,
91 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>`,
92 distill: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,
93 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>`,
94 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>`,
95 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>`,
96 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>`,
97 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>`,
98 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>`,
99 delight: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,
100 overdrive: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,
101 };
102
103 const ACTIONS = [
104 { value: 'impeccable', label: 'Freeform' },
105 { value: 'bolder', label: 'Bolder' },
106 { value: 'quieter', label: 'Quieter' },
107 { value: 'distill', label: 'Distill' },
108 { value: 'polish', label: 'Polish' },
109 { value: 'typeset', label: 'Typeset' },
110 { value: 'colorize', label: 'Colorize' },
111 { value: 'layout', label: 'Layout' },
112 { value: 'adapt', label: 'Adapt' },
113 { value: 'animate', label: 'Animate' },
114 { value: 'delight', label: 'Delight' },
115 { value: 'overdrive', label: 'Overdrive' },
116 ];
117
118 const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions'];
119 const LIVE_UI_SURFACES = [
120 { key: 'global-bottom-bar', ids: [PREFIX + '-global-bar', PREFIX + '-global-bar-brand', PREFIX + '-pick-toggle', PREFIX + '-insert-toggle', PREFIX + '-detect-toggle', PREFIX + '-detect-badge', PREFIX + '-design-toggle', PREFIX + '-page-chat', PREFIX + '-page-chat-input', PREFIX + '-page-chat-voice'] },
121 { key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] },
122 { key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-configure-input-wrap', PREFIX + '-input', PREFIX + '-configure-voice'] },
123 { key: 'action-picker', ids: [PREFIX + '-picker'] },
124 { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] },
125 { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] },
126 { key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] },
127 { key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] },
128 { key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] },
129 { key: 'insert-mode-chrome', ids: [PREFIX + '-insert-line', PREFIX + '-insert-placeholder', PREFIX + '-placeholder-resize', PREFIX + '-insert-input', PREFIX + '-insert-voice', PREFIX + '-insert-create', PREFIX + '-insert-create-tooltip'] },
130 { key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] },
131 { key: 'design-system-panel', ids: [PREFIX + '-design-host'] },
132 { key: 'toasts-and-errors', ids: [PREFIX + '-toast'] },
133 { key: 'css-isolation-boundary', ids: [PREFIX + '-root'] },
134 ];
135 const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))];
136
137 //
138 // State
139 //
140
141 let state = 'IDLE';
142 let hoveredElement = null;
143 let selectedElement = null;
144 let currentSessionId = null;
145 let expectedVariants = 0;
146 let arrivedVariants = 0;
147 let visibleVariant = 0;
148 let svelteComponentSession = null;
149 let svelteRuntimePromise = null;
150 let pendingSvelteComponentRetryObserver = null;
151 let currentSourceFile = null;
152 let currentPreviewFile = null;
153 let currentPreviewMode = null;
154 let recoveryWaitingForAnchor = false;
155 let pendingAcceptedSession = null;
156 let variantObserver = null;
157 let variantSelectionInFlight = false;
158 let variantSelectionPromise = null;
159 let recoveringEmptyCycling = false;
160 let hasProjectContext = false;
161 let selectedAction = 'impeccable';
162 let selectedCount = 3;
163 const browserOwner = sessionState.owner;
164 let checkpointTimer = null;
165
166 // Scroll lock - holds window.scrollY at a fixed value while the session is
167 // active, so HMR DOM patches and variant swaps can't drift the page. See
168 // startScrollLock / stopScrollLock below.
169 let scrollLockObserver = null;
170 let scrollLockTargetY = null;
171 let scrollLockRaf = null;
172 let scrollLockAbort = null;
173
174 // Dedicated key for scroll position - SEPARATE from LS_KEY so that
175 // saveSession's state updates don't clobber a carefully-captured scrollY.
176 // (Previously: saveSession wrote scrollY alongside state, so every call
177 // during resume overwrote the pre-reload value with whatever the browser
178 // had landed on, typically 0.)
179 function writeScrollY(y) { sessionState.writeScrollY(y); }
180 function readScrollY() { return sessionState.readScrollY(); }
181 function clearScrollY() { sessionState.clearScrollY(); }
182
183 // Pre-empt the browser: apply manual scroll restoration and jump to the
184 // saved scrollY at script-parse time. Retries on fonts.ready and load
185 // are essential: scrollTo(y) clamps to the current document.scrollHeight,
186 // which is often hundreds of pixels short of the final value until
187 // async-loaded fonts swap in and reflow.
188 try {
189 history.scrollRestoration = 'manual';
190 const savedY = readScrollY();
191 if (savedY != null) {
192 const apply = () => {
193 if (Math.abs(window.scrollY - savedY) > 0.5) {
194 window.scrollTo(0, savedY);
195 }
196 };
197 apply();
198 if (document.fonts?.ready) document.fonts.ready.then(apply).catch(() => {});
199 window.addEventListener('load', apply, { once: true });
200 }
201 } catch {}
202
203 // UI refs
204 let highlightEl = null;
205 let tooltipEl = null;
206 let barEl = null;
207 let barHideSeq = 0;
208 let pickerEl = null;
209 let toastEl = null;
210 let scrollRaf = null;
211 let editBadgeEl = null;
212 let editBadgeProxyRoot = null;
213 let editBadgeProxyByTarget = new Map();
214
215 //
216 // Helpers
217 //
218
219 function own(el) {
220 return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]'));
221 }
222
223 function pickable(el) {
224 if (!el || el.nodeType !== 1) return false;
225 if (SKIP_TAGS.has(el.tagName.toLowerCase())) return false;
226 if (own(el)) return false;
227 const r = el.getBoundingClientRect();
228 return r.width >= 20 && r.height >= 20;
229 }
230
231 function desc(el) {
232 if (!el) return '';
233 let s = el.tagName.toLowerCase();
234 if (el.id) s += '#' + el.id;
235 else if (el.classList.length) s += '.' + [...el.classList].slice(0, 2).join('.');
236 return s;
237 }
238
239 function rectIsUsableAnchor(rect) {
240 return !!rect && rect.width > 0.5 && rect.height > 0.5;
241 }
242
243 function makeFrozenAnchor(el) {
244 if (!el || !el.getBoundingClientRect) return null;
245 const r = el.getBoundingClientRect();
246 if (!rectIsUsableAnchor(r)) return null;
247 const rect = {
248 x: r.x, y: r.y,
249 top: r.top, left: r.left,
250 right: r.right, bottom: r.bottom,
251 width: r.width, height: r.height,
252 };
253 return {
254 __impeccableFrozenAnchor: true,
255 tagName: el.tagName || 'DIV',
256 id: el.id || '',
257 classList: el.classList ? [...el.classList] : [],
258 hasAttribute: () => false,
259 getBoundingClientRect: () => rect,
260 };
261 }
262
263 function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
264
265 function cssId(id) {
266 if (window.CSS?.escape) return CSS.escape(id);
267 return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
268 }
269
270 function liveUiRoot() {
271 const root = window.__IMPECCABLE_LIVE_UI_ROOT__;
272 if (root && typeof root.appendChild === 'function') return root;
273 return document.body;
274 }
275
276 function uiAppend(el) {
277 liveUiRoot().appendChild(el);
278 return el;
279 }
280
281 function uiAppendStyle(styleEl) {
282 const root = liveUiRoot();
283 if (root && root !== document.body) root.appendChild(styleEl);
284 else document.head.appendChild(styleEl);
285 return styleEl;
286 }
287
288 function uiGetById(id) {
289 const root = liveUiRoot();
290 if (root?.getElementById) {
291 const found = root.getElementById(id);
292 if (found) return found;
293 }
294 if (root?.querySelector) {
295 const found = root.querySelector('#' + cssId(id));
296 if (found) return found;
297 }
298 return document.getElementById(id);
299 }
300
301 function activeElementDeep() {
302 let active = document.activeElement;
303 while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
304 return active;
305 }
306
307 window.__IMPECCABLE_LIVE_CHROME_CORE__ = {
308 version: 1,
309 adapter: window.__IMPECCABLE_LIVE_ADAPTER__ || 'dom',
310 mountContract: LIVE_CHROME_MOUNT_CONTRACT,
311 surfaces: LIVE_UI_SURFACES,
312 componentIds: LIVE_UI_COMPONENT_IDS,
313 root: liveUiRoot,
314 append: uiAppend,
315 appendStyle: uiAppendStyle,
316 getById: uiGetById,
317 activeElementDeep,
318 debugState: () => ({
319 state,
320 currentSessionId,
321 expectedVariants,
322 arrivedVariants,
323 visibleVariant,
324 savedSession: loadSession(),
325 sourceFile: currentSourceFile,
326 previewFile: currentPreviewFile,
327 previewMode: currentPreviewMode,
328 barText: barEl?.textContent || null,
329 barConnected: !!barEl?.isConnected,
330 hasSvelteComponentSession: !!svelteComponentSession,
331 mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0,
332 pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver,
333 recoveryWaitingForAnchor,
334 evtSourceReadyState: evtSource ? evtSource.readyState : null,
335 }),
336 };
337
338 // Modal-aware chrome: keep our floating UI clickable inside Radix /
339 // Headless UI / vaul portals.
340 //
341 // Two host-page behaviors break us when the picked element lives inside a
342 // modal dialog:
343 //
344 // 1. Modal scroll-lock disables outside pointer events. Radix's
345 // `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
346 // while a modal is open and only restores `auto` on the layer. Our
347 // chrome inherits `none` from <body> and becomes unclickable.
348 // 2. The dialog's outside-interaction handler (Radix's
349 // `usePointerDownOutside`) listens at document level and dismisses
350 // the dialog whenever a `pointerdown` lands outside the layer node.
351 // Our chrome is a sibling of <body>, so Radix classifies our clicks
352 // as outside and tears the dialog down mid-task.
353 //
354 // We can't reliably re-parent our chrome into the dialog subtree (z-index
355 // stacking, scroll containers, theming all become host-page concerns), so
356 // we defang both behaviors at our root:
357 //
358 // - `pointer-events: auto !important` overrides the inherited `none`.
359 // - Stop `pointerdown` / `mousedown` propagation so the document-level
360 // dismiss listener never fires for our clicks.
361 // - Stop `focusin` propagation so any focus shifts inside our chrome
362 // don't read as "focus moved outside the dialog" to focus traps.
363 //
364 // Click events still bubble normally - only the early pointer/focus
365 // signals that drive outside-interaction detection are silenced.
366 function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
367 if (!rootEl) return;
368 if (setPointerEvents) {
369 rootEl.style.setProperty('pointer-events', 'auto', 'important');
370 }
371 const stop = (e) => e.stopPropagation();
372 rootEl.addEventListener('pointerdown', stop);
373 rootEl.addEventListener('mousedown', stop);
374 rootEl.addEventListener('focusin', stop);
375 }
376
377 //
378 // Highlight overlay
379 //
380
381 function initHighlight() {
382 highlightEl = document.createElement('div');
383 highlightEl.id = PREFIX + '-highlight';
384 Object.assign(highlightEl.style, {
385 position: 'fixed', top: '0', left: '0', width: '0', height: '0',
386 border: '2px solid ' + C.brand, borderRadius: '3px',
387 pointerEvents: 'none', zIndex: Z.highlight, boxSizing: 'border-box',
388 transition: HIGHLIGHT_TRANSITION,
389 display: 'none', opacity: '0',
390 });
391 uiAppend(highlightEl);
392
393 tooltipEl = document.createElement('div');
394 tooltipEl.id = PREFIX + '-tooltip';
395 Object.assign(tooltipEl.style, {
396 position: 'fixed',
397 background: C.ink, color: C.white,
398 fontFamily: MONO, fontSize: '10px', fontWeight: '500',
399 padding: '2px 6px', borderRadius: '3px',
400 zIndex: Z.highlight + 1, pointerEvents: 'none',
401 whiteSpace: 'nowrap', display: 'none',
402 letterSpacing: '0.02em',
403 transition: TOOLTIP_TRANSITION,
404 });
405 uiAppend(tooltipEl);
406 }
407
408 function showHighlight(el) {
409 if (!el || !highlightEl) return;
410 if (el.hasAttribute?.('data-impeccable-insert-placeholder')) return;
411 const r = el.getBoundingClientRect();
412 const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px';
413 const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px';
414 const tipTop = r.top - 20;
415 const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px';
416 const tipX = Math.max(4, r.left) + 'px';
417 tooltipEl.textContent = desc(el);
418
419 const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0';
420 if (hiWasHidden) {
421 // Snap to first target without animating from (0,0), then fade in.
422 highlightEl.style.transition = 'none';
423 Object.assign(highlightEl.style, { top, left, width, height, display: 'block' });
424 tooltipEl.style.transition = 'none';
425 Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' });
426 void highlightEl.offsetWidth;
427 highlightEl.style.transition = HIGHLIGHT_TRANSITION;
428 highlightEl.style.opacity = '1';
429 tooltipEl.style.transition = TOOLTIP_TRANSITION;
430 tooltipEl.style.opacity = '1';
431 } else {
432 Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' });
433 Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' });
434 }
435 }
436
437 function hideHighlight() {
438 if (highlightEl) { highlightEl.style.opacity = '0'; highlightEl.style.display = 'none'; }
439 if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; }
440 }
441
442 //
443 // Annotation overlay (comment pins + kinpaku strokes)
444 //
445 // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned
446 // sibling of <body> mirroring selectedElement's bounding rect. Click (no
447 // drag) drops a comment pin; drag paints a kinpaku SVG stroke. All coords
448 // are stored in element-local CSS px so they survive scroll / resize and
449 // correlate directly with the captured PNG.
450 //
451
452 const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click
453 const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it
454 let annotOverlayEl = null;
455 let annotSvgEl = null;
456 let annotPinsEl = null;
457 let annotClearChipEl = null;
458 let annotState = { comments: [], strokes: [] };
459 let annotActive = false;
460 // `annotPointer` is either:
461 // { kind: 'new', x0, y0, moved, strokeEl, strokePoints } creating a stroke/pin
462 // { kind: 'pin', idx, startPointer, startPin, moved } dragging an existing pin
463 let annotPointer = null;
464 let annotEditing = null; // { idx, input, wrapEl }
465 let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete
466 let placeholderResizeLayerEl = null;
467 let placeholderResizeDrag = null;
468
469 function initAnnotOverlay() {
470 annotOverlayEl = document.createElement('div');
471 annotOverlayEl.id = PREFIX + '-annot';
472 Object.assign(annotOverlayEl.style, {
473 position: 'fixed', top: '0', left: '0', width: '0', height: '0',
474 pointerEvents: 'auto', zIndex: Z.highlight + 2,
475 display: 'none', overflow: 'visible',
476 cursor: 'crosshair', touchAction: 'none',
477 });
478
479 annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
480 annotSvgEl.id = PREFIX + '-annot-svg';
481 Object.assign(annotSvgEl.style, {
482 position: 'absolute', top: '0', left: '0',
483 width: '100%', height: '100%',
484 // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via
485 // pointer-events=stroke so gaps still fall through to the overlay.
486 pointerEvents: 'none', overflow: 'visible',
487 });
488 annotOverlayEl.appendChild(annotSvgEl);
489
490 annotPinsEl = document.createElement('div');
491 annotPinsEl.id = PREFIX + '-annot-pins';
492 Object.assign(annotPinsEl.style, {
493 position: 'absolute', inset: '0',
494 pointerEvents: 'none',
495 });
496 annotOverlayEl.appendChild(annotPinsEl);
497
498 annotClearChipEl = document.createElement('div');
499 annotClearChipEl.id = PREFIX + '-annot-clear';
500 annotClearChipEl.dataset.annotClear = 'true';
501 annotClearChipEl.textContent = 'Clear';
502 Object.assign(annotClearChipEl.style, {
503 position: 'absolute', top: '8px', right: '8px',
504 background: C.ink, color: C.white,
505 fontFamily: FONT, fontSize: '10px', fontWeight: '500',
506 letterSpacing: '0.08em', textTransform: 'uppercase',
507 padding: '5px 12px', borderRadius: '999px',
508 cursor: 'pointer', pointerEvents: 'auto',
509 display: 'none', userSelect: 'none',
510 boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
511 });
512 annotOverlayEl.appendChild(annotClearChipEl);
513
514 placeholderResizeLayerEl = document.createElement('div');
515 placeholderResizeLayerEl.id = PREFIX + '-placeholder-resize';
516 Object.assign(placeholderResizeLayerEl.style, {
517 position: 'absolute',
518 inset: '0',
519 pointerEvents: 'none',
520 display: 'none',
521 zIndex: '2',
522 });
523 annotOverlayEl.appendChild(placeholderResizeLayerEl);
524
525 annotOverlayEl.addEventListener('pointerdown', onAnnotDown);
526 annotOverlayEl.addEventListener('pointermove', onAnnotMove);
527 annotOverlayEl.addEventListener('pointerup', onAnnotUp);
528 annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
529 uiAppend(annotOverlayEl);
530 // Modal-host friendliness: pointer-events is already 'auto' on this
531 // overlay; we only need to silence the host's outside-interaction
532 // listeners. Don't override pointer-events here (the overlay toggles
533 // visibility via display:none, which is fine).
534 defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
535 }
536
537 function updateClearChip() {
538 if (!annotClearChipEl) return;
539 const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0;
540 annotClearChipEl.style.display = hasAny ? 'block' : 'none';
541 }
542
543 function showAnnotOverlay(el) {
544 if (!annotOverlayEl || !el) return;
545 annotActive = true;
546 positionAnnotOverlay(el);
547 annotOverlayEl.style.display = 'block';
548 syncPlaceholderResizeHandles();
549 }
550
551 function hideAnnotOverlay() {
552 annotActive = false;
553 placeholderResizeDrag = null;
554 if (annotOverlayEl) annotOverlayEl.style.display = 'none';
555 syncPlaceholderResizeHandles();
556 // Drop any in-progress edit without touching annotState - clearAnnotations
557 // (if the caller is exiting configure mode) handles state reset.
558 annotEditing = null;
559 }
560
561 function positionAnnotOverlay(el) {
562 if (!annotOverlayEl || !el) return;
563 const r = el.getBoundingClientRect();
564 Object.assign(annotOverlayEl.style, {
565 top: r.top + 'px', left: r.left + 'px',
566 width: r.width + 'px', height: r.height + 'px',
567 });
568 annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height);
569 syncPlaceholderResizeHandles();
570 }
571
572 function clearAnnotations() {
573 annotState.comments = [];
574 annotState.strokes = [];
575 if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild);
576 if (annotPinsEl) annotPinsEl.innerHTML = '';
577 annotPointer = null;
578 annotEditing = null;
579 annotLastPinClick = { idx: -1, time: 0 };
580 updateClearChip();
581 }
582
583 // Rebuild the SVG layer. Each stroke gets a wider invisible hit path
584 // beneath the visible kinpaku path so clicks register on thin lines.
585 function redrawStrokes() {
586 while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild);
587 annotState.strokes.forEach((s, idx) => {
588 const d = pointsToPath(s.points);
589 const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path');
590 hit.setAttribute('d', d);
591 hit.setAttribute('stroke', 'transparent');
592 hit.setAttribute('stroke-width', '16');
593 hit.setAttribute('stroke-linecap', 'round');
594 hit.setAttribute('stroke-linejoin', 'round');
595 hit.setAttribute('fill', 'none');
596 hit.setAttribute('pointer-events', 'stroke');
597 hit.style.cursor = 'pointer';
598 hit.dataset.annotStroke = String(idx);
599 annotSvgEl.appendChild(hit);
600 const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path');
601 visible.setAttribute('d', d);
602 visible.setAttribute('stroke', C.brand);
603 visible.setAttribute('stroke-width', '3');
604 visible.setAttribute('stroke-linecap', 'round');
605 visible.setAttribute('stroke-linejoin', 'round');
606 visible.setAttribute('fill', 'none');
607 visible.setAttribute('pointer-events', 'none');
608 annotSvgEl.appendChild(visible);
609 });
610 updateClearChip();
611 }
612
613 function localCoords(e) {
614 const rect = annotOverlayEl.getBoundingClientRect();
615 return { x: e.clientX - rect.left, y: e.clientY - rect.top };
616 }
617
618 function onAnnotDown(e) {
619 if (!annotActive) return;
620
621 // 0) Insert placeholder edge resize - wins over draw / pins.
622 const resizeEdge = e.target.closest?.('[data-impeccable-placeholder-resize]')?.dataset.impeccablePlaceholderResize;
623 if (resizeEdge && configureKind === 'insert' && placeholderElement) {
624 startPlaceholderEdgeResize(resizeEdge, e);
625 return;
626 }
627
628 // 1) Clear chip → wipe all annotations
629 if (e.target.closest?.('[data-annot-clear]')) {
630 if (annotEditing) annotEditing = null;
631 clearAnnotations();
632 renderAllPins();
633 redrawStrokes();
634 e.stopPropagation(); e.preventDefault();
635 return;
636 }
637
638 // 2) Stroke hit path → delete that stroke
639 const strokeHit = e.target.closest?.('[data-annot-stroke]');
640 if (strokeHit) {
641 const idx = parseInt(strokeHit.dataset.annotStroke, 10);
642 if (Number.isInteger(idx)) {
643 annotState.strokes.splice(idx, 1);
644 redrawStrokes();
645 }
646 e.stopPropagation(); e.preventDefault();
647 return;
648 }
649
650 // 3) Pin → drag, edit, or delete-on-double-click
651 const pinWrap = e.target.closest?.('[data-annot-pin]');
652 if (pinWrap) {
653 const idx = parseInt(pinWrap.dataset.annotPin, 10);
654 if (!Number.isInteger(idx)) return;
655 // Double-click (two pointerdowns on the same pin within window) → delete.
656 const now = Date.now();
657 if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) {
658 if (annotEditing && annotEditing.idx === idx) annotEditing = null;
659 annotState.comments.splice(idx, 1);
660 annotLastPinClick = { idx: -1, time: 0 };
661 renderAllPins();
662 e.stopPropagation(); e.preventDefault();
663 return;
664 }
665 annotLastPinClick = { idx, time: now };
666 // If editing a different pin, commit that edit before starting here.
667 if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
668 // If already editing THIS pin and the user clicked the dot, let the
669 // input keep focus (don't start a drag - the click wasn't meant as one).
670 if (annotEditing && annotEditing.idx === idx) return;
671 const p = localCoords(e);
672 const pin = annotState.comments[idx];
673 annotPointer = {
674 kind: 'pin', idx,
675 startPointer: p,
676 startPin: { x: pin.x, y: pin.y },
677 moved: false,
678 };
679 try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {}
680 e.stopPropagation(); e.preventDefault();
681 return;
682 }
683
684 // 4) Empty area → commit any open edit, then start new annotation
685 if (annotEditing) {
686 finalizeEditingPin();
687 e.stopPropagation(); e.preventDefault();
688 return;
689 }
690 const p = localCoords(e);
691 annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null };
692 try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {}
693 e.stopPropagation(); e.preventDefault();
694 }
695
696 function onAnnotMove(e) {
697 if (!annotActive) return;
698
699 if (placeholderResizeDrag) {
700 const d = placeholderResizeDrag;
701 const next = resizePlaceholderFromEdge(
702 d.start,
703 d.edge,
704 e.clientX - d.startX,
705 e.clientY - d.startY,
706 d.parentWidth,
707 );
708 applyPlaceholderDimensions(next);
709 e.stopPropagation();
710 return;
711 }
712
713 if (!annotPointer) return;
714 const p = localCoords(e);
715
716 if (annotPointer.kind === 'pin') {
717 const dx = p.x - annotPointer.startPointer.x;
718 const dy = p.y - annotPointer.startPointer.y;
719 if (!annotPointer.moved) {
720 if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
721 annotPointer.moved = true;
722 }
723 const pin = annotState.comments[annotPointer.idx];
724 if (!pin) { annotPointer = null; return; }
725 pin.x = annotPointer.startPin.x + dx;
726 pin.y = annotPointer.startPin.y + dy;
727 renderAllPins();
728 e.stopPropagation();
729 return;
730 }
731
732 // kind === 'new'
733 const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0;
734 if (!annotPointer.moved) {
735 if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
736 annotPointer.moved = true;
737 const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
738 strokeEl.setAttribute('stroke', C.brand);
739 strokeEl.setAttribute('stroke-width', '3');
740 strokeEl.setAttribute('stroke-linecap', 'round');
741 strokeEl.setAttribute('stroke-linejoin', 'round');
742 strokeEl.setAttribute('fill', 'none');
743 strokeEl.setAttribute('pointer-events', 'none');
744 annotSvgEl.appendChild(strokeEl);
745 annotPointer.strokeEl = strokeEl;
746 annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]];
747 }
748 annotPointer.strokePoints.push([p.x, p.y]);
749 annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints));
750 e.stopPropagation();
751 }
752
753 function pointsToPath(points) {
754 if (!points || points.length === 0) return '';
755 let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1);
756 for (let i = 1; i < points.length; i++) {
757 d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1);
758 }
759 return d;
760 }
761
762 function onAnnotUp(e) {
763 if (placeholderResizeDrag) {
764 try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {}
765 placeholderResizeDrag = null;
766 e.stopPropagation();
767 return;
768 }
769 if (!annotActive || !annotPointer) return;
770
771 if (annotPointer.kind === 'pin') {
772 const wasDrag = annotPointer.moved;
773 const idx = annotPointer.idx;
774 try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {}
775 annotPointer = null;
776 if (wasDrag) {
777 // A drag is an intentional reposition; a follow-up click shouldn't be
778 // interpreted as a double-click-to-delete.
779 annotLastPinClick = { idx: -1, time: 0 };
780 } else {
781 beginEditPin(idx);
782 }
783 e.stopPropagation();
784 return;
785 }
786
787 // kind === 'new'
788 const wasDrag = annotPointer.moved;
789 if (wasDrag) {
790 annotState.strokes.push({ points: annotPointer.strokePoints });
791 // Swap the temporary preview SVG path for the full render with hit paths.
792 redrawStrokes();
793 } else {
794 const idx = annotState.comments.length;
795 annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' });
796 renderAllPins();
797 beginEditPin(idx);
798 }
799 try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {}
800 annotPointer = null;
801 if (configureKind === 'insert') syncInsertCreateButton();
802 e.stopPropagation();
803 }
804
805 function renderAllPins() {
806 annotPinsEl.innerHTML = '';
807 annotState.comments.forEach((c, idx) => {
808 annotPinsEl.appendChild(buildPinElement(c, idx));
809 });
810 updateClearChip();
811 }
812
813 function buildPinElement(comment, idx) {
814 const interactive = idx >= 0;
815 const wrap = document.createElement('div');
816 if (interactive) wrap.dataset.annotPin = String(idx);
817 Object.assign(wrap.style, {
818 position: 'absolute',
819 left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px',
820 pointerEvents: interactive ? 'auto' : 'none',
821 display: 'flex', alignItems: 'flex-start', gap: '6px',
822 cursor: interactive ? 'grab' : 'default',
823 touchAction: 'none',
824 });
825 const dot = document.createElement('div');
826 Object.assign(dot.style, {
827 width: '14px', height: '14px', borderRadius: '50%',
828 background: C.brand, border: '2px solid ' + C.white,
829 boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
830 flexShrink: '0',
831 });
832 wrap.appendChild(dot);
833
834 if (comment.text) {
835 const bubble = document.createElement('div');
836 bubble.textContent = comment.text;
837 Object.assign(bubble.style, {
838 background: C.ink, color: C.white,
839 fontFamily: FONT, fontSize: '12px', lineHeight: '1.4',
840 padding: '4px 8px', borderRadius: '3px',
841 marginTop: '-2px', maxWidth: '220px',
842 pointerEvents: 'none', whiteSpace: 'pre-wrap',
843 wordBreak: 'break-word',
844 });
845 wrap.appendChild(bubble);
846 }
847 return wrap;
848 }
849
850 function beginEditPin(idx) {
851 const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]');
852 if (!wrapEl) return;
853 // Strip any existing bubble (but keep the dot)
854 wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove());
855 const input = document.createElement('input');
856 input.type = 'text';
857 input.placeholder = 'Note…';
858 Object.assign(input.style, {
859 background: C.ink, color: C.white,
860 fontFamily: FONT, fontSize: '12px', lineHeight: '1.4',
861 padding: '4px 8px', borderRadius: '3px',
862 border: '1px solid ' + C.brand,
863 outline: 'none', marginTop: '-2px',
864 width: '220px', pointerEvents: 'auto',
865 });
866 const originalText = annotState.comments[idx].text || '';
867 input.value = originalText;
868 wrapEl.appendChild(input);
869 annotEditing = { idx, input, wrapEl, originalText };
870 input.addEventListener('keydown', onAnnotInputKey, true);
871 input.addEventListener('blur', () => {
872 // Fires on both focus-loss and programmatic blur; commit unless we
873 // already handled it.
874 if (annotEditing && annotEditing.input === input) finalizeEditingPin();
875 });
876 // Stop clicks/pointerdowns inside the input from bubbling to the overlay
877 ['pointerdown', 'click'].forEach(ev => {
878 input.addEventListener(ev, e => e.stopPropagation());
879 });
880 setTimeout(() => input.focus(), 0);
881 }
882
883 function onAnnotInputKey(e) {
884 if (e.key === 'Enter') {
885 e.preventDefault(); e.stopPropagation();
886 finalizeEditingPin();
887 } else if (e.key === 'Escape') {
888 e.preventDefault(); e.stopPropagation();
889 cancelEditingPin();
890 } else {
891 // Keep arrows / backspace from hitting global handlers
892 e.stopPropagation();
893 }
894 }
895
896 function finalizeEditingPin() {
897 if (!annotEditing) return;
898 const { idx, input } = annotEditing;
899 const text = input.value.trim();
900 annotEditing = null;
901 if (text) annotState.comments[idx].text = text;
902 else annotState.comments.splice(idx, 1);
903 renderAllPins();
904 }
905
906 function cancelEditingPin() {
907 if (!annotEditing) return;
908 const { idx, originalText } = annotEditing;
909 annotEditing = null;
910 // If the pin had text before this edit, restore it. If it was a
911 // just-created empty pin, Escape removes it.
912 if (originalText) {
913 annotState.comments[idx].text = originalText;
914 } else {
915 annotState.comments.splice(idx, 1);
916 }
917 renderAllPins();
918 }
919
920 // Build a detached annotation subtree suitable for injection into the clone
921 // modern-screenshot creates. Coordinates are element-local so this slots
922 // straight into an element that's been made position:relative. Takes an
923 // explicit snapshot so it works after annotState has been cleared.
924 function buildAnnotationsForCapture(rect, snapshot) {
925 const comments = snapshot ? snapshot.comments : annotState.comments;
926 const strokes = snapshot ? snapshot.strokes : annotState.strokes;
927 if (comments.length === 0 && strokes.length === 0) return null;
928 const wrap = document.createElement('div');
929 Object.assign(wrap.style, {
930 position: 'absolute', top: '0', left: '0',
931 width: rect.width + 'px', height: rect.height + 'px',
932 pointerEvents: 'none', overflow: 'visible',
933 });
934 if (strokes.length > 0) {
935 const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
936 svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height);
937 Object.assign(svg.style, {
938 position: 'absolute', top: '0', left: '0',
939 width: '100%', height: '100%', overflow: 'visible',
940 });
941 for (const s of strokes) {
942 const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
943 path.setAttribute('stroke', C.brand);
944 path.setAttribute('stroke-width', '3');
945 path.setAttribute('stroke-linecap', 'round');
946 path.setAttribute('stroke-linejoin', 'round');
947 path.setAttribute('fill', 'none');
948 path.setAttribute('d', pointsToPath(s.points));
949 svg.appendChild(path);
950 }
951 wrap.appendChild(svg);
952 }
953 for (const c of comments) {
954 // idx=-1 means non-interactive; pointerEvents stay off in the clone
955 wrap.appendChild(buildPinElement(c, -1));
956 }
957 return wrap;
958 }
959
960 //
961 // Element context extraction
962 //
963
964 function stripManualEditRuntimeState(root) {
965 if (!root || root.nodeType !== 1) return;
966 unwrapMixedContentTextNodes(root);
967 const nodes = [root, ...root.querySelectorAll('[data-impeccable-editable], [data-impeccable-original-text], [data-impeccable-text-wrap]')];
968 for (const node of nodes) {
969 const runtimeEditable = node.hasAttribute('data-impeccable-editable')
970 || node.hasAttribute('data-impeccable-original-text');
971 node.removeAttribute('data-impeccable-editable');
972 node.removeAttribute('data-impeccable-original-text');
973 node.removeAttribute('data-impeccable-text-wrap');
974 if (runtimeEditable) {
975 node.removeAttribute('contenteditable');
976 if (node.style) {
977 node.style.userSelect = '';
978 node.style.cursor = '';
979 node.style.outline = '';
980 node.style.webkitUserModify = '';
981 if (!node.getAttribute('style')?.trim()) node.removeAttribute('style');
982 }
983 }
984 }
985 }
986
987 function sanitizedContextOuterHTML(el, maxLength) {
988 if (!el || !el.cloneNode) return '';
989 const clone = el.cloneNode(true);
990 stripManualEditRuntimeState(clone);
991 return clone.outerHTML ? clone.outerHTML.slice(0, maxLength) : '';
992 }
993
994 function extractContext(el) {
995 const cs = getComputedStyle(el);
996 const r = el.getBoundingClientRect();
997 const props = {};
998 for (const sheet of document.styleSheets) {
999 try {
1000 for (const rule of sheet.cssRules) {
1001 if (rule.style) for (let i = 0; i < rule.style.length; i++) {
1002 const p = rule.style[i];
1003 if (p.startsWith('--') && !props[p]) {
1004 const v = cs.getPropertyValue(p).trim();
1005 if (v) props[p] = v;
1006 }
1007 }
1008 }
1009 } catch { /* cross-origin */ }
1010 }
1011 return {
1012 tagName: el.tagName.toLowerCase(), id: el.id || null,
1013 classes: [...el.classList],
1014 textContent: (el.textContent || '').slice(0, 500),
1015 outerHTML: sanitizedContextOuterHTML(el, 10000),
1016 computedStyles: {
1017 'font-family': cs.fontFamily, 'font-size': cs.fontSize,
1018 'font-weight': cs.fontWeight, 'line-height': cs.lineHeight,
1019 'color': cs.color, 'background': cs.background,
1020 'background-color': cs.backgroundColor,
1021 'padding': cs.padding, 'margin': cs.margin,
1022 'display': cs.display, 'position': cs.position,
1023 'gap': cs.gap, 'border-radius': cs.borderRadius,
1024 'box-shadow': cs.boxShadow,
1025 },
1026 cssCustomProperties: props,
1027 parentContext: el.parentElement
1028 ? '<' + el.parentElement.tagName.toLowerCase()
1029 + (el.parentElement.id ? ' id="' + el.parentElement.id + '"' : '')
1030 + (el.parentElement.className ? ' class="' + el.parentElement.className + '"' : '')
1031 + '>'
1032 : null,
1033 boundingRect: { width: Math.round(r.width), height: Math.round(r.height) },
1034 };
1035 }
1036
1037 const MANUAL_CONTEXT_SKIP = { script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1 };
1038
1039 function contextElementForManualEdit(selectedEl, rows, ops) {
1040 if (!selectedEl) return selectedEl;
1041 const leafOnly =
1042 rows && rows.length === 1 && rows[0] && rows[0].el === selectedEl;
1043 if (!leafOnly) return selectedEl;
1044
1045 const editedTexts = new Set();
1046 for (const row of rows || []) addManualContextText(editedTexts, row.text);
1047 for (const op of ops || []) {
1048 addManualContextText(editedTexts, op.originalText);
1049 addManualContextText(editedTexts, op.newText);
1050 }
1051
1052 let cur = selectedEl.parentElement;
1053 let depth = 0;
1054 while (cur && cur !== document.body && cur !== document.documentElement && depth < 4) {
1055 if (own(cur)) break;
1056 if (isUsefulManualEditContext(cur, selectedEl, editedTexts)) return cur;
1057 cur = cur.parentElement;
1058 depth++;
1059 }
1060 return selectedEl;
1061 }
1062
1063 function isUsefulManualEditContext(candidate, leafEl, editedTexts) {
1064 if (!candidate || !candidate.contains(leafEl)) return false;
1065 if (!candidate.id && candidate.classList.length === 0 && candidate.children.length < 2) return false;
1066 return collectManualContextPieces(candidate, editedTexts).length > 0;
1067 }
1068
1069 function collectManualContextPieces(rootEl, editedTexts) {
1070 const pieces = [];
1071 function walk(node) {
1072 if (!node) return;
1073 if (node.nodeType === 3) {
1074 const text = normalizeManualContextText(node.nodeValue);
1075 if (isMeaningfulManualContextPiece(text, editedTexts)) pieces.push(text);
1076 return;
1077 }
1078 if (node.nodeType !== 1) return;
1079 const tag = node.tagName.toLowerCase();
1080 if (MANUAL_CONTEXT_SKIP[tag]) return;
1081 if (node !== rootEl && own(node)) return;
1082 for (const child of node.childNodes) walk(child);
1083 }
1084 walk(rootEl);
1085 return pieces.slice(0, 12);
1086 }
1087
1088 function addManualContextText(set, value) {
1089 const text = normalizeManualContextText(value);
1090 if (text) set.add(text);
1091 }
1092
1093 function isMeaningfulManualContextPiece(text, editedTexts) {
1094 if (!text || text.length < 3 || text.length > 160) return false;
1095 if (/^[\d.,+\-%\s]+$/.test(text)) return false;
1096 return !editedTexts.has(text);
1097 }
1098
1099 function normalizeManualContextText(value) {
1100 return String(value || '').replace(/\s+/g, ' ').trim();
1101 }
1102
1103 //
1104 // The Bar - one floating element, three modes
1105 //
1106
1107 // Contextual-bar palette. Cached at init so every build*Row reads a
1108 // consistent set of colors; detectPageTheme runs once rather than on every
1109 // phase transition.
1110 let BP = null;
1111
1112 // Bar shadow variants. The default projects down + subtle around. When
1113 // the Tune popover opens below the bar, a downward shadow lands on the
1114 // dark popover and reads as a bright ghost line. We swap to UP-only while
1115 // tune is open below so the popover's top edge is clean.
1116 const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
1117 const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
1118 const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
1119
1120 function initBar() {
1121 BP = barPaletteForTheme(detectPageTheme());
1122 barEl = document.createElement('div');
1123 barEl.id = PREFIX + '-bar';
1124 Object.assign(barEl.style, {
1125 position: 'fixed', zIndex: Z.bar,
1126 display: 'none', opacity: '0',
1127 transform: 'translateY(6px)',
1128 transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
1129 background: BP.surface,
1130 border: '1px solid ' + BP.border,
1131 borderRadius: '8px',
1132 boxShadow: BP.shadow,
1133 transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
1134 fontFamily: FONT, fontSize: '13px', color: BP.text,
1135 padding: '6px',
1136 maxWidth: '520px', minWidth: '320px',
1137 });
1138 uiAppend(barEl);
1139 defangOutsideHandlers(barEl);
1140 }
1141
1142 function positionBar() {
1143 if (!barEl) return;
1144 const anchor = resolveBarAnchor();
1145 if (!anchor) return;
1146 const r = anchor.getBoundingClientRect();
1147 const barH = barEl.offsetHeight || 44;
1148 const barW = barEl.offsetWidth || 380;
1149 const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room
1150 const GAP = 8;
1151
1152 // Prefer below the element; fall back to above; if neither fits (element
1153 // taller than viewport), pin to a stable viewport anchor so the bar
1154 // doesn't teleport between top and bottom as the user scrolls.
1155 let top;
1156 const belowTop = r.bottom + GAP;
1157 const aboveTop = r.top - barH - GAP;
1158 if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {
1159 top = belowTop;
1160 } else if (aboveTop >= GAP) {
1161 top = aboveTop;
1162 } else {
1163 top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;
1164 }
1165
1166 let left = r.left + (r.width - barW) / 2;
1167 if (left < GAP) left = GAP;
1168 if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;
1169 Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });
1170 }
1171
1172 function showBar(mode) {
1173 barHideSeq += 1;
1174 if (mode === 'cycling' && !ensureCyclingRenderable('show-bar')) return;
1175 barEl.innerHTML = '';
1176 if (mode === 'configure') {
1177 barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow());
1178 if (configureKind === 'insert') syncInsertCreateButton();
1179 } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
1180 else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
1181 barEl.style.display = 'block';
1182 positionBar();
1183 requestAnimationFrame(() => {
1184 barEl.style.opacity = '1';
1185 barEl.style.transform = 'translateY(0)';
1186 syncPageChatFocus('show-bar');
1187 });
1188 }
1189
1190 function hideBar() {
1191 if (!barEl) return;
1192 const hideSeq = ++barHideSeq;
1193 stopVoice({ suppressSubmit: true });
1194 if (configureKind === 'insert') clearInsertPicking();
1195 barEl.style.opacity = '0';
1196 barEl.style.transform = 'translateY(6px)';
1197 setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250);
1198 hideActionPicker();
1199 closeTunePopover();
1200 if (state === 'EDITING') restoreInlineEditDrafts();
1201 disableInlineEdit();
1202 }
1203
1204 function updateBarContent(mode) {
1205 if (!barEl || barEl.style.display === 'none') return;
1206 if (mode === 'cycling' && !ensureCyclingRenderable('update-bar')) return;
1207 barEl.innerHTML = '';
1208 // Reset bar styling to the kinpaku picker palette
1209 barEl.style.background = BP.surface;
1210 barEl.style.border = '1px solid ' + BP.border;
1211 barEl.style.boxShadow = BP.shadow;
1212 if (mode === 'configure') {
1213 barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow());
1214 if (configureKind === 'insert') syncInsertCreateButton();
1215 } else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
1216 else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
1217 else if (mode === 'saving') barEl.appendChild(buildSavingRow());
1218 else if (mode === 'confirmed') {
1219 barEl.appendChild(buildConfirmedRow());
1220 barEl.style.background = 'oklch(95% 0.05 145)';
1221 barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)';
1222 }
1223 syncPageChatFocus('update-bar-content');
1224 }
1225
1226 // Configure row
1227
1228 function syncConfigureInputChrome() {
1229 const wrap = uiGetById(PREFIX + '-configure-input-wrap');
1230 const input = uiGetById(PREFIX + '-input');
1231 if (!wrap || !input) return;
1232 const focused = activeElementDeep() === input;
1233 wrap.dataset.inputFocused = focused ? 'true' : 'false';
1234 wrap.dataset.voiceListening = (voiceListening && voiceCtx?.mode === 'configure') ? 'true' : 'false';
1235 wrap.style.borderColor = (voiceListening && voiceCtx?.mode === 'configure')
1236 ? BP.patinaSoft
1237 : (focused ? BP.accentSoft : BP.hairline);
1238 }
1239
1240 // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs)
1241
1242 function detectInsertAxisFromStyle(style) {
1243 const display = style?.display || 'block';
1244 if (display.includes('flex')) {
1245 const dir = style.flexDirection || 'row';
1246 return dir.startsWith('row') ? 'row' : 'column';
1247 }
1248 if (display === 'grid' || display === 'inline-grid') {
1249 const flow = style.gridAutoFlow || 'row';
1250 if (flow.includes('column')) return 'column';
1251 const cols = (style.gridTemplateColumns || '').trim();
1252 if (cols && cols !== 'none') {
1253 const colCount = cols.split(/\s+/).filter(Boolean).length;
1254 if (colCount > 1) return 'row';
1255 }
1256 return 'row';
1257 }
1258 return 'column';
1259 }
1260
1261 function detectInsertAxis(parent) {
1262 if (!parent || parent.nodeType !== 1) return 'column';
1263 const st = getComputedStyle(parent);
1264 return detectInsertAxisFromStyle({
1265 display: st.display,
1266 flexDirection: st.flexDirection,
1267 gridTemplateColumns: st.gridTemplateColumns,
1268 gridAutoFlow: st.gridAutoFlow,
1269 });
1270 }
1271
1272 function layoutFlowChildren(parent) {
1273 if (!parent) return [];
1274 return [...parent.children]
1275 .filter(pickable)
1276 .map((el) => ({ el, rect: el.getBoundingClientRect() }));
1277 }
1278
1279 function computeInsertPosition(clientX, clientY, rect, axis) {
1280 axis = axis || 'column';
1281 if (!rect) return 'after';
1282 if (axis === 'row') {
1283 if (!Number.isFinite(rect.width) || rect.width <= 0) return 'after';
1284 return clientX < rect.left + rect.width / 2 ? 'before' : 'after';
1285 }
1286 if (!Number.isFinite(rect.height) || rect.height <= 0) return 'after';
1287 return clientY < rect.top + rect.height / 2 ? 'before' : 'after';
1288 }
1289
1290 function groupSiblingRows(siblings, rowThreshold) {
1291 rowThreshold = rowThreshold ?? 8;
1292 const sorted = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left);
1293 const rows = [];
1294 for (const entry of sorted) {
1295 let placed = false;
1296 for (const row of rows) {
1297 if (Math.abs(entry.rect.top - row[0].rect.top) <= rowThreshold) {
1298 row.push(entry);
1299 placed = true;
1300 break;
1301 }
1302 }
1303 if (!placed) rows.push([entry]);
1304 }
1305 return rows;
1306 }
1307
1308 function horizontalOverlap(a, b) {
1309 const left = Math.max(a.left, b.left);
1310 const right = Math.min(a.right, b.right);
1311 return Math.max(0, right - left);
1312 }
1313
1314 function hitSiblingInsertGap(clientX, clientY, siblings, opts) {
1315 opts = opts || {};
1316 if (!siblings || siblings.length < 2) return null;
1317 const slop = opts.slop ?? 12;
1318 const minOverlap = opts.minOverlap ?? 0.25;
1319
1320 for (const row of groupSiblingRows(siblings)) {
1321 if (row.length < 2) continue;
1322 const sorted = [...row].sort((a, b) => a.rect.left - b.rect.left);
1323 for (let i = 0; i < sorted.length - 1; i++) {
1324 const a = sorted[i];
1325 const b = sorted[i + 1];
1326 const aRight = a.rect.right;
1327 const bLeft = b.rect.left;
1328 if (bLeft <= aRight) continue;
1329 const top = Math.max(a.rect.top, b.rect.top);
1330 const bottom = Math.min(a.rect.bottom, b.rect.bottom);
1331 const span = bottom - top;
1332 const minH = Math.min(a.rect.height, b.rect.height);
1333 if (span < minH * minOverlap) continue;
1334 const inX = clientX >= aRight - slop && clientX <= bLeft + slop;
1335 const inY = clientY >= top - slop && clientY <= bottom + slop;
1336 if (!inX || !inY) continue;
1337 return {
1338 anchor: b.el,
1339 position: 'before',
1340 axis: 'row',
1341 line: { axis: 'row', left: (aRight + bLeft) / 2, top, width: 0, height: span },
1342 };
1343 }
1344 }
1345
1346 const sortedCol = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left);
1347 for (let i = 0; i < sortedCol.length - 1; i++) {
1348 const a = sortedCol[i];
1349 const b = sortedCol[i + 1];
1350 const overlap = horizontalOverlap(a.rect, b.rect);
1351 const minW = Math.min(a.rect.width, b.rect.width);
1352 if (overlap < minW * minOverlap) continue;
1353 const gapTop = a.rect.bottom;
1354 const gapBottom = b.rect.top;
1355 if (gapBottom <= gapTop) continue;
1356 const overlapLeft = Math.max(a.rect.left, b.rect.left);
1357 const overlapRight = Math.min(a.rect.right, b.rect.right);
1358 const inY = clientY >= gapTop - slop && clientY <= gapBottom + slop;
1359 const inX = clientX >= overlapLeft - slop && clientX <= overlapRight + slop;
1360 if (!inY || !inX) continue;
1361 return {
1362 anchor: b.el,
1363 position: 'before',
1364 axis: 'column',
1365 line: { axis: 'column', top: (gapTop + gapBottom) / 2, left: overlapLeft, width: overlap, height: 0 },
1366 };
1367 }
1368 return null;
1369 }
1370
1371 function insertLineCoords(rect, position, axis) {
1372 axis = axis || 'column';
1373 if (axis === 'row') {
1374 const x = position === 'before' ? rect.left - 2 : rect.right + 2;
1375 return { axis: 'row', top: rect.top, left: x, width: 0, height: rect.height };
1376 }
1377 const y = position === 'before' ? rect.top - 2 : rect.bottom + 2;
1378 return { axis: 'column', top: y, left: rect.left, width: rect.width, height: 0 };
1379 }
1380
1381 function resolveInsertHover({ clientX, clientY, target, rect, axis, siblings }) {
1382 const gap = hitSiblingInsertGap(clientX, clientY, siblings);
1383 if (gap) return gap;
1384 const position = computeInsertPosition(clientX, clientY, rect, axis);
1385 const line = insertLineCoords(rect, position, axis);
1386 return { anchor: target, position, axis, line };
1387 }
1388
1389 function cursorForInsertAxis(axis) {
1390 return axis === 'row' ? 'ew-resize' : 'ns-resize';
1391 }
1392
1393 function placeholderSizing({ axis, parentDisplay, parentWidth, anchorFlex }) {
1394 const display = parentDisplay || 'block';
1395 const w = Number.isFinite(parentWidth) ? parentWidth : 0;
1396 if (axis === 'row') {
1397 if (display.includes('flex')) {
1398 const flex = anchorFlex && anchorFlex !== 'none' && anchorFlex !== '0 1 auto'
1399 ? anchorFlex
1400 : '1 1 0';
1401 return { kind: 'flex', flex, minWidth: 0 };
1402 }
1403 if (display === 'grid' || display === 'inline-grid') return { kind: 'auto' };
1404 }
1405 if (w >= PLACEHOLDER_MIN_WIDTH) return { kind: 'percent' };
1406 return {
1407 kind: 'explicit',
1408 width: Math.max(PLACEHOLDER_MIN_WIDTH, w || PLACEHOLDER_MIN_WIDTH),
1409 };
1410 }
1411
1412 function placeholderWidthIsImplicit(kind) {
1413 return kind === 'flex' || kind === 'percent' || kind === 'auto';
1414 }
1415
1416 function applyPlaceholderSizingStyles(placeholder, sizing) {
1417 placeholder.dataset.impeccablePlaceholderWidth = sizing.kind;
1418 placeholder.style.flex = '';
1419 placeholder.style.minWidth = '';
1420 placeholder.style.maxWidth = '';
1421 placeholder.style.width = '';
1422 if (sizing.kind === 'flex') {
1423 placeholder.style.flex = sizing.flex;
1424 placeholder.style.minWidth = sizing.minWidth + 'px';
1425 } else if (sizing.kind === 'percent') {
1426 placeholder.style.width = '100%';
1427 placeholder.style.maxWidth = '100%';
1428 } else if (sizing.kind === 'explicit') {
1429 placeholder.style.width = sizing.width + 'px';
1430 }
1431 }
1432
1433 function materializePlaceholderWidth(placeholder) {
1434 if (!placeholder) return;
1435 const kind = placeholder.dataset.impeccablePlaceholderWidth;
1436 if (!placeholderWidthIsImplicit(kind)) return;
1437 const w = Math.max(PLACEHOLDER_MIN_WIDTH, Math.round(placeholder.offsetWidth));
1438 placeholder.style.flex = '';
1439 placeholder.style.minWidth = '';
1440 placeholder.style.maxWidth = '';
1441 placeholder.style.width = w + 'px';
1442 placeholder.dataset.impeccablePlaceholderWidth = 'explicit';
1443 }
1444
1445 function canCreateInsert({ prompt, comments, strokes }) {
1446 const hasPrompt = typeof prompt === 'string' && prompt.trim().length > 0;
1447 const hasComments = Array.isArray(comments) && comments.length > 0;
1448 const hasStrokes = Array.isArray(strokes) && strokes.some(
1449 (s) => Array.isArray(s?.points) && s.points.length >= 2,
1450 );
1451 return hasPrompt || hasComments || hasStrokes;
1452 }
1453
1454 function insertCreateDisabledReason({ prompt, comments, strokes }) {
1455 if (canCreateInsert({ prompt, comments, strokes })) return null;
1456 return 'Add a prompt or annotate the placeholder to create';
1457 }
1458
1459 function clampPlaceholderSize(width, height, parentWidth) {
1460 const maxW = Math.max(PLACEHOLDER_MIN_WIDTH, parentWidth || PLACEHOLDER_MIN_WIDTH);
1461 return {
1462 width: Math.min(maxW, Math.max(PLACEHOLDER_MIN_WIDTH, Math.round(width))),
1463 height: Math.max(PLACEHOLDER_MIN_HEIGHT, Math.round(height)),
1464 };
1465 }
1466
1467 function cursorForPlaceholderEdge(edge) {
1468 if (edge === 'n' || edge === 's') return 'ns-resize';
1469 if (edge === 'e' || edge === 'w') return 'ew-resize';
1470 return 'default';
1471 }
1472
1473 function resizePlaceholderFromEdge(start, edge, dx, dy, parentWidth) {
1474 const base = {
1475 width: start.width,
1476 height: start.height,
1477 marginLeft: start.marginLeft ?? 0,
1478 marginTop: start.marginTop ?? 0,
1479 };
1480 if (edge === 'e') base.width = start.width + dx;
1481 else if (edge === 'w') {
1482 base.width = start.width - dx;
1483 base.marginLeft = start.marginLeft + dx;
1484 } else if (edge === 's') base.height = start.height + dy;
1485 else if (edge === 'n') {
1486 base.height = start.height - dy;
1487 base.marginTop = start.marginTop + dy;
1488 }
1489 const clamped = clampPlaceholderSize(base.width, base.height, parentWidth);
1490 if (edge === 'w') base.marginLeft = start.marginLeft + start.width - clamped.width;
1491 else if (edge === 'n') base.marginTop = start.marginTop + start.height - clamped.height;
1492 return {
1493 width: clamped.width,
1494 height: clamped.height,
1495 marginLeft: Math.round(base.marginLeft),
1496 marginTop: Math.round(base.marginTop),
1497 };
1498 }
1499
1500 function ensureInsertLine() {
1501 if (insertLineEl) return insertLineEl;
1502 insertLineEl = document.createElement('div');
1503 insertLineEl.id = PREFIX + '-insert-line';
1504 Object.assign(insertLineEl.style, {
1505 position: 'fixed',
1506 zIndex: String(Z.highlight),
1507 height: '0',
1508 borderTop: '2px dotted ' + C.brand,
1509 pointerEvents: 'none',
1510 display: 'none',
1511 opacity: '0.9',
1512 });
1513 uiAppend(insertLineEl);
1514 defangOutsideHandlers(insertLineEl);
1515 return insertLineEl;
1516 }
1517
1518 function showInsertLine(resolved) {
1519 if (!resolved?.anchor || !resolved.line) return;
1520 const line = ensureInsertLine();
1521 const coords = resolved.line;
1522 if (coords.axis === 'row') {
1523 Object.assign(line.style, {
1524 display: 'block',
1525 top: coords.top + 'px',
1526 left: coords.left + 'px',
1527 width: '0',
1528 height: coords.height + 'px',
1529 borderTop: 'none',
1530 borderLeft: '2px dotted ' + C.brand,
1531 });
1532 } else {
1533 Object.assign(line.style, {
1534 display: 'block',
1535 top: coords.top + 'px',
1536 left: coords.left + 'px',
1537 width: coords.width + 'px',
1538 height: '0',
1539 borderLeft: 'none',
1540 borderTop: '2px dotted ' + C.brand,
1541 });
1542 }
1543 insertHoverAnchor = resolved.anchor;
1544 insertHoverPosition = resolved.position;
1545 insertHoverAxis = resolved.axis || 'column';
1546 }
1547
1548 function hideInsertLine() {
1549 if (!insertLineEl) return;
1550 insertLineEl.style.display = 'none';
1551 insertHoverAnchor = null;
1552 insertHoverPosition = null;
1553 insertHoverAxis = null;
1554 syncPageInteractionCursor();
1555 }
1556
1557 let pageInteractionCursorActive = false;
1558
1559 /** Page-level cursor while insert mode is choosing a before/after edge. */
1560 function syncPageInteractionCursor() {
1561 let next = '';
1562 if (state === 'PICKING' && insertActive) {
1563 next = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : '';
1564 }
1565 if (next) {
1566 document.documentElement.style.cursor = next;
1567 pageInteractionCursorActive = true;
1568 } else if (pageInteractionCursorActive) {
1569 document.documentElement.style.cursor = '';
1570 pageInteractionCursorActive = false;
1571 }
1572 }
1573
1574 /** Element used to position the floating bar / shader during a session. */
1575 function resolveBarAnchor() {
1576 if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
1577 const anchor = resolveSvelteComponentAnchor();
1578 if (anchor) return anchor;
1579 }
1580 if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
1581 const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
1582 if (wrapper) {
1583 const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
1584 if (variantCount > 0 && visibleVariant > 0) {
1585 const visEl = pickVariantContent(wrapper, visibleVariant);
1586 if (visEl) return visEl;
1587 }
1588 if (state === 'GENERATING') {
1589 const ph = ensureInsertPlaceholder();
1590 if (ph) return ph;
1591 if (insertAnchorElement && document.body.contains(insertAnchorElement)) return insertAnchorElement;
1592 }
1593 }
1594 }
1595 if (selectedElement && document.body.contains(selectedElement)) return selectedElement;
1596 if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
1597 if (insertAnchorElement && document.body.contains(insertAnchorElement)) return insertAnchorElement;
1598 return null;
1599 }
1600
1601 function removeInsertPlaceholderDom() {
1602 if (placeholderElement) {
1603 placeholderElement.remove();
1604 placeholderElement = null;
1605 }
1606 placeholderResizeDrag = null;
1607 syncPlaceholderResizeHandles();
1608 }
1609
1610 function finalizeInsertSession() {
1611 removeInsertPlaceholderDom();
1612 insertAnchorElement = null;
1613 insertAnchorPosition = null;
1614 insertAnchorLayoutAxis = null;
1615 insertPlaceholderSnapshot = null;
1616 if (configureKind === 'insert') configureKind = 'replace';
1617 }
1618
1619 function buildInsertPlaceholderSnapshotFromDom(anchor, placeholder) {
1620 return {
1621 width: Math.round(placeholder.offsetWidth || 0),
1622 height: Math.round(placeholder.offsetHeight || PLACEHOLDER_DEFAULT_HEIGHT),
1623 marginLeft: parseFloat(placeholder.style.marginLeft) || 0,
1624 marginTop: parseFloat(placeholder.style.marginTop) || 0,
1625 position: insertAnchorPosition || 'before',
1626 layoutAxis: insertAnchorLayoutAxis || 'column',
1627 anchorTag: anchor.tagName || 'DIV',
1628 anchorClasses: anchor.className || '',
1629 anchorText: (anchor.textContent || '').trim().slice(0, 120),
1630 };
1631 }
1632
1633 function findInsertAnchorInDom() {
1634 if (insertAnchorElement && document.body.contains(insertAnchorElement)) return insertAnchorElement;
1635 const snap = insertPlaceholderSnapshot;
1636 if (!snap) return null;
1637 const tag = (snap.anchorTag || 'div').toLowerCase();
1638 const cls = (snap.anchorClasses || '').split(/\s+/).filter(Boolean)[0];
1639 const needle = snap.anchorText || '';
1640 const sel = cls ? tag + '.' + cls : tag;
1641 const candidates = document.querySelectorAll(sel);
1642 for (const candidate of candidates) {
1643 if (own(candidate)) continue;
1644 if (needle && !(candidate.textContent || '').includes(needle.slice(0, 40))) continue;
1645 return candidate;
1646 }
1647 return null;
1648 }
1649
1650 function isInsertGeneratingSession() {
1651 if (state !== 'GENERATING' || !currentSessionId) return false;
1652 const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
1653 return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
1654 }
1655
1656 /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
1657 function ensureInsertPlaceholder() {
1658 if (!isInsertGeneratingSession()) return placeholderElement;
1659 const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
1660 const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
1661 if (variantCount > 0) return placeholderElement;
1662 if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
1663
1664 const anchor = findInsertAnchorInDom();
1665 if (!anchor) return null;
1666
1667 insertAnchorElement = anchor;
1668 const position = insertPlaceholderSnapshot?.position || insertAnchorPosition || 'before';
1669 const axis = insertPlaceholderSnapshot?.layoutAxis || insertAnchorLayoutAxis;
1670 const ph = createInsertPlaceholder(anchor, position, axis);
1671 if (!ph) return null;
1672
1673 if (insertPlaceholderSnapshot) {
1674 applyPlaceholderDimensions({
1675 width: insertPlaceholderSnapshot.width,
1676 height: insertPlaceholderSnapshot.height,
1677 marginLeft: insertPlaceholderSnapshot.marginLeft,
1678 marginTop: insertPlaceholderSnapshot.marginTop,
1679 });
1680 }
1681 selectedElement = ph;
1682 return ph;
1683 }
1684
1685 function applyPlaceholderDimensions({ width, height, marginLeft, marginTop }) {
1686 const ph = placeholderElement;
1687 if (!ph) return;
1688 materializePlaceholderWidth(ph);
1689 ph.style.width = width + 'px';
1690 ph.style.height = height + 'px';
1691 ph.style.marginLeft = marginLeft ? marginLeft + 'px' : '';
1692 ph.style.marginTop = marginTop ? marginTop + 'px' : '';
1693 positionAnnotOverlay(ph);
1694 positionBar();
1695 }
1696
1697 function showOrUpdateCyclingBar() {
1698 if (barEl && barEl.style.display !== 'none') updateBarContent('cycling');
1699 else showBar('cycling');
1700 }
1701
1702 function buildPlaceholderResizeHandles() {
1703 if (!placeholderResizeLayerEl) return;
1704 placeholderResizeLayerEl.innerHTML = '';
1705 const hit = 10;
1706 const half = hit / 2;
1707 const specs = [
1708 { edge: 'n', top: -half, left: 0, right: 0, height: hit },
1709 { edge: 's', bottom: -half, left: 0, right: 0, height: hit },
1710 { edge: 'e', top: 0, bottom: 0, right: -half, width: hit },
1711 { edge: 'w', top: 0, bottom: 0, left: -half, width: hit },
1712 ];
1713 for (const spec of specs) {
1714 const handle = el('div', {
1715 position: 'absolute',
1716 pointerEvents: 'auto',
1717 cursor: cursorForPlaceholderEdge(spec.edge),
1718 });
1719 if (spec.top != null) handle.style.top = spec.top + 'px';
1720 if (spec.bottom != null) handle.style.bottom = spec.bottom + 'px';
1721 if (spec.left != null) handle.style.left = spec.left + 'px';
1722 if (spec.right != null) handle.style.right = spec.right + 'px';
1723 if (spec.width != null) handle.style.width = spec.width + 'px';
1724 if (spec.height != null) handle.style.height = spec.height + 'px';
1725 handle.dataset.impeccablePlaceholderResize = spec.edge;
1726 handle.setAttribute('aria-label', 'Resize placeholder');
1727 handle.title = 'Drag to resize';
1728 placeholderResizeLayerEl.appendChild(handle);
1729 }
1730 }
1731
1732 function syncPlaceholderResizeHandles() {
1733 if (!placeholderResizeLayerEl) return;
1734 const show = configureKind === 'insert' && annotActive && !!placeholderElement && state === 'CONFIGURING';
1735 placeholderResizeLayerEl.style.display = show ? 'block' : 'none';
1736 if (!show) {
1737 placeholderResizeLayerEl.innerHTML = '';
1738 return;
1739 }
1740 if (!placeholderResizeLayerEl.childElementCount) buildPlaceholderResizeHandles();
1741 }
1742
1743 function startPlaceholderEdgeResize(edge, e) {
1744 const ph = placeholderElement;
1745 if (!ph || configureKind !== 'insert') return;
1746 materializePlaceholderWidth(ph);
1747 placeholderResizeDrag = {
1748 edge,
1749 startX: e.clientX,
1750 startY: e.clientY,
1751 start: {
1752 width: ph.offsetWidth,
1753 height: ph.offsetHeight,
1754 marginLeft: parseFloat(ph.style.marginLeft) || 0,
1755 marginTop: parseFloat(ph.style.marginTop) || 0,
1756 },
1757 parentWidth: ph.parentNode?.getBoundingClientRect().width || PLACEHOLDER_MIN_WIDTH,
1758 pointerId: e.pointerId,
1759 };
1760 try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {}
1761 e.stopPropagation();
1762 e.preventDefault();
1763 }
1764
1765 function createInsertPlaceholder(anchor, position, layoutAxis) {
1766 removeInsertPlaceholderDom();
1767 const parent = anchor.parentNode;
1768 if (!parent) return null;
1769 const axis = layoutAxis || detectInsertAxis(parent);
1770 const pst = getComputedStyle(parent);
1771 const ast = getComputedStyle(anchor);
1772 const sizing = placeholderSizing({
1773 axis,
1774 parentDisplay: pst.display,
1775 parentWidth: parent.getBoundingClientRect().width,
1776 anchorFlex: ast.flex,
1777 });
1778 const placeholder = document.createElement('div');
1779 placeholder.id = PREFIX + '-insert-placeholder';
1780 placeholder.setAttribute('data-impeccable-insert-placeholder', 'true');
1781 placeholder.setAttribute('aria-hidden', 'true');
1782 Object.assign(placeholder.style, {
1783 boxSizing: 'border-box',
1784 height: PLACEHOLDER_DEFAULT_HEIGHT + 'px',
1785 minHeight: PLACEHOLDER_MIN_HEIGHT + 'px',
1786 border: '2px dotted ' + BP.accent,
1787 borderRadius: '0',
1788 background: 'transparent',
1789 opacity: '1',
1790 position: 'relative',
1791 marginLeft: '',
1792 marginTop: '',
1793 });
1794 applyPlaceholderSizingStyles(placeholder, sizing);
1795 if (position === 'before') parent.insertBefore(placeholder, anchor);
1796 else parent.insertBefore(placeholder, anchor.nextSibling);
1797 placeholderElement = placeholder;
1798 insertAnchorElement = anchor;
1799 insertAnchorPosition = position;
1800 insertAnchorLayoutAxis = axis;
1801 return placeholder;
1802 }
1803
1804 function clearInsertPicking() {
1805 hideInsertLine();
1806 finalizeInsertSession();
1807 }
1808
1809 function isInsertCreateEnabled(btn) {
1810 btn = btn || uiGetById(PREFIX + '-insert-create');
1811 return !!btn && btn.getAttribute('aria-disabled') !== 'true';
1812 }
1813
1814 let insertCreateTooltipEl = null;
1815
1816 function ensureInsertCreateTooltip() {
1817 if (insertCreateTooltipEl) return insertCreateTooltipEl;
1818 insertCreateTooltipEl = el('div', {
1819 position: 'fixed',
1820 display: 'none',
1821 zIndex: String(Z.bar + 7),
1822 pointerEvents: 'none',
1823 maxWidth: '240px',
1824 padding: '6px 9px',
1825 borderRadius: '7px',
1826 background: BP.chatSurface,
1827 border: '1px solid ' + BP.hairline,
1828 boxShadow: BP.shadow,
1829 color: BP.text,
1830 fontFamily: FONT,
1831 fontSize: '11px',
1832 fontWeight: '500',
1833 lineHeight: '1.35',
1834 });
1835 insertCreateTooltipEl.id = PREFIX + '-insert-create-tooltip';
1836 uiAppend(insertCreateTooltipEl);
1837 return insertCreateTooltipEl;
1838 }
1839
1840 function showInsertCreateTooltip(anchor, message) {
1841 if (!anchor || !message) return;
1842 const tip = ensureInsertCreateTooltip();
1843 tip.textContent = message;
1844 tip.style.display = 'block';
1845 const r = anchor.getBoundingClientRect();
1846 const tipW = tip.offsetWidth;
1847 const tipH = tip.offsetHeight;
1848 const left = Math.max(8, Math.min(window.innerWidth - tipW - 8, r.left + r.width / 2 - tipW / 2));
1849 const top = Math.max(8, r.top - tipH - 8);
1850 tip.style.left = left + 'px';
1851 tip.style.top = top + 'px';
1852 }
1853
1854 function hideInsertCreateTooltip() {
1855 if (!insertCreateTooltipEl) return;
1856 insertCreateTooltipEl.style.display = 'none';
1857 }
1858
1859 function insertCreateGateState(input) {
1860 return {
1861 prompt: input?.value ?? '',
1862 comments: annotState.comments,
1863 strokes: annotState.strokes,
1864 };
1865 }
1866
1867 function syncInsertCreateButton(btn, input) {
1868 btn = btn || uiGetById(PREFIX + '-insert-create');
1869 input = input || uiGetById(PREFIX + '-insert-input');
1870 if (!btn || !input) return;
1871 const gate = insertCreateGateState(input);
1872 const ok = canCreateInsert(gate);
1873 const reason = ok ? 'Create variants' : insertCreateDisabledReason(gate);
1874 btn.setAttribute('aria-disabled', ok ? 'false' : 'true');
1875 btn.setAttribute('aria-label', reason);
1876 if (ok) {
1877 hideInsertCreateTooltip();
1878 btn.style.background = BP.accent;
1879 btn.style.color = C.ink;
1880 btn.style.border = 'none';
1881 btn.style.opacity = '1';
1882 btn.style.cursor = 'pointer';
1883 } else {
1884 btn.style.background = 'transparent';
1885 btn.style.color = BP.textDim;
1886 btn.style.border = '1px solid ' + BP.hairline;
1887 btn.style.opacity = '0.72';
1888 btn.style.cursor = 'not-allowed';
1889 }
1890 }
1891
1892 function buildConfigureRow() {
1893 const controlsLocked = pendingApplyInFlight === true;
1894 const row = el('div', {
1895 display: 'flex', alignItems: 'center', gap: '6px',
1896 });
1897
1898 // Action pill - dark graphite chip (matches kinpaku-kit .live-demo-ctx-pill)
1899 const pill = el('button', {
1900 display: 'inline-flex', alignItems: 'center', gap: '4px',
1901 padding: '5px 10px', borderRadius: '6px',
1902 background: BP.chatSurface, color: BP.text,
1903 fontFamily: FONT, fontSize: '12px', fontWeight: '500',
1904 border: '1px solid ' + BP.hairline, cursor: 'pointer',
1905 transition: 'background 0.12s ease, border-color 0.12s ease, transform 0.1s ease',
1906 whiteSpace: 'nowrap', flexShrink: '0',
1907 });
1908 pill.textContent = actionLabel() + ' \u25BE';
1909 pill.disabled = controlsLocked;
1910 pill.style.cursor = controlsLocked ? 'not-allowed' : 'pointer';
1911 pill.style.opacity = controlsLocked ? '0.58' : '1';
1912 if (controlsLocked) pill.title = 'Apply is still running';
1913 pill.addEventListener('mouseenter', () => {
1914 if (controlsLocked) return;
1915 pill.style.background = BP.accentSoft;
1916 pill.style.borderColor = BP.accent;
1917 });
1918 pill.addEventListener('mouseleave', () => {
1919 if (controlsLocked) return;
1920 pill.style.background = BP.chatSurface;
1921 pill.style.borderColor = BP.hairline;
1922 });
1923 pill.addEventListener('mousedown', () => { if (!controlsLocked) pill.style.transform = 'scale(0.97)'; });
1924 pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
1925 pill.addEventListener('click', (e) => {
1926 e.stopPropagation();
1927 if (controlsLocked) { showManualApplyBusyToast(); return; }
1928 toggleActionPicker();
1929 });
1930 row.appendChild(pill);
1931
1932 // Prompt field - same chat-surface chrome as the bottom Steer bar
1933 const inputWrap = el('div', {
1934 display: 'inline-flex', alignItems: 'center',
1935 flex: '1', minWidth: '0', height: '28px',
1936 borderRadius: '7px',
1937 background: BP.chatSurface,
1938 border: '1px solid ' + BP.hairline,
1939 overflow: 'hidden',
1940 transition: 'border-color 0.15s ease',
1941 });
1942 inputWrap.id = PREFIX + '-configure-input-wrap';
1943
1944 const input = document.createElement('input');
1945 input.id = PREFIX + '-input';
1946 input.type = 'text';
1947 input.placeholder = selectedAction === 'impeccable' ? 'describe what you want…' : 'refine further (optional)…';
1948 input.setAttribute('aria-label', 'Describe the change');
1949 Object.assign(input.style, {
1950 flex: '1', minWidth: '0', width: '100%',
1951 padding: '0 6px', border: 'none', background: 'transparent',
1952 fontFamily: FONT, fontSize: '11.5px', color: BP.text,
1953 outline: 'none',
1954 });
1955 input.disabled = controlsLocked;
1956 if (controlsLocked) {
1957 input.placeholder = 'apply is running...';
1958 input.style.cursor = 'not-allowed';
1959 input.style.opacity = '0.58';
1960 }
1961
1962 const voiceBtn = el('button', {
1963 display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
1964 padding: '0', boxSizing: 'border-box',
1965 width: '28px', height: '28px', flexShrink: '0',
1966 border: 'none', background: 'transparent',
1967 color: BP.textDim, cursor: 'pointer',
1968 transition: 'color 0.12s ease, background 0.12s ease',
1969 });
1970 voiceBtn.id = PREFIX + '-configure-voice';
1971 voiceBtn.type = 'button';
1972 voiceBtn.setAttribute('aria-label', 'Voice input');
1973 voiceBtn.innerHTML = ICON_PAGE_VOICE;
1974 voiceBtn.disabled = controlsLocked;
1975 voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer';
1976 voiceBtn.style.opacity = controlsLocked ? '0.58' : '1';
1977
1978 if (!uiGetById(PREFIX + '-configure-input-style')) {
1979 const s = document.createElement('style');
1980 s.id = PREFIX + '-configure-input-style';
1981 s.textContent =
1982 '@keyframes impeccable-configure-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' +
1983 '#' + PREFIX + '-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }' +
1984 '#' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: impeccable-configure-voice-pulse 1.1s ease-in-out infinite; }' +
1985 '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' +
1986 '#' + PREFIX + '-configure-voice:hover { background: oklch(78% 0.12 82 / 0.12); }';
1987 uiAppendStyle(s);
1988 }
1989
1990 input.addEventListener('focus', () => syncConfigureInputChrome());
1991 input.addEventListener('blur', () => syncConfigureInputChrome());
1992 input.addEventListener('keydown', (e) => {
1993 if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; }
1994 if (e.key === 'Escape') {
1995 e.stopPropagation();
1996 e.preventDefault();
1997 input.blur();
1998 disableInlineEdit();
1999 hideBar();
2000 renderEditBadge('hidden');
2001 state = 'PICKING';
2002 syncPageChatFocus('configure-input-escape');
2003 return;
2004 }
2005 // Let arrow keys pass through to the element picker when the input is empty
2006 if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !input.value) return;
2007 e.stopPropagation();
2008 });
2009
2010 voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation());
2011 voiceBtn.addEventListener('click', (e) => {
2012 e.stopPropagation();
2013 if (controlsLocked) { showManualApplyBusyToast(); return; }
2014 toggleConfigureVoice();
2015 });
2016
2017 inputWrap.appendChild(input);
2018 inputWrap.appendChild(voiceBtn);
2019 row.appendChild(inputWrap);
2020 syncConfigureInputChrome();
2021
2022 // Variant count toggle
2023 const count = el('button', {
2024 display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
2025 boxSizing: 'border-box', height: '28px', padding: '0 6px',
2026 borderRadius: '5px',
2027 border: '1px solid ' + BP.hairline, background: 'transparent',
2028 fontFamily: MONO, fontSize: '11px', fontWeight: '600',
2029 color: BP.textDim, cursor: 'pointer',
2030 transition: 'color 0.12s ease, border-color 0.12s ease',
2031 flexShrink: '0', whiteSpace: 'nowrap',
2032 });
2033 count.textContent = '\u00D7' + selectedCount;
2034 count.title = 'Variants: click to change';
2035 count.disabled = controlsLocked;
2036 count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer';
2037 count.style.opacity = controlsLocked ? '0.58' : '1';
2038 if (controlsLocked) count.title = 'Apply is still running';
2039 count.addEventListener('mouseenter', () => { if (!controlsLocked) { count.style.color = BP.text; count.style.borderColor = BP.text; } });
2040 count.addEventListener('mouseleave', () => { if (!controlsLocked) { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; } });
2041 count.addEventListener('click', (e) => {
2042 e.stopPropagation();
2043 if (controlsLocked) { showManualApplyBusyToast(); return; }
2044 selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
2045 count.textContent = '\u00D7' + selectedCount;
2046 });
2047 row.appendChild(count);
2048
2049 // Go button
2050 const go = el('button', {
2051 display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
2052 boxSizing: 'border-box', height: '28px', padding: '0 12px',
2053 borderRadius: '6px',
2054 border: 'none', background: BP.accent, color: C.ink,
2055 fontFamily: FONT, fontSize: '12px', fontWeight: '600',
2056 cursor: 'pointer',
2057 transition: 'filter 0.12s ease, transform 0.1s ease',
2058 flexShrink: '0', whiteSpace: 'nowrap',
2059 });
2060 go.textContent = 'Go \u2192';
2061 go.disabled = controlsLocked;
2062 go.style.cursor = controlsLocked ? 'not-allowed' : 'pointer';
2063 go.style.opacity = controlsLocked ? '0.58' : '1';
2064 if (controlsLocked) go.title = 'Apply is still running';
2065 go.addEventListener('mouseenter', () => { if (!controlsLocked) go.style.filter = 'brightness(1.1)'; });
2066 go.addEventListener('mouseleave', () => go.style.filter = 'none');
2067 go.addEventListener('mousedown', () => { if (!controlsLocked) go.style.transform = 'scale(0.97)'; });
2068 go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
2069 go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
2070 row.appendChild(go);
2071
2072 // Auto-focus input after a beat
2073 if (!controlsLocked) setTimeout(() => input.focus(), 60);
2074
2075 return row;
2076 }
2077
2078 function buildInsertConfigureRow() {
2079 const controlsLocked = pendingApplyInFlight === true;
2080 const row = el('div', {
2081 display: 'flex', alignItems: 'center', gap: '6px',
2082 });
2083
2084 const inputWrap = el('div', {
2085 display: 'inline-flex', alignItems: 'center',
2086 flex: '1', minWidth: '0', height: '28px',
2087 borderRadius: '7px',
2088 background: BP.chatSurface,
2089 border: '1px solid ' + BP.hairline,
2090 overflow: 'hidden',
2091 transition: 'border-color 0.15s ease',
2092 });
2093 inputWrap.id = PREFIX + '-insert-input-wrap';
2094 inputWrap.addEventListener('pointerdown', (e) => e.stopPropagation());
2095 inputWrap.addEventListener('mousedown', (e) => e.stopPropagation());
2096 inputWrap.addEventListener('click', (e) => e.stopPropagation());
2097
2098 const input = document.createElement('input');
2099 input.id = PREFIX + '-insert-input';
2100 input.type = 'text';
2101 input.placeholder = 'describe what to insert…';
2102 input.setAttribute('aria-label', 'Describe the new element');
2103 Object.assign(input.style, {
2104 flex: '1', minWidth: '0', width: '100%',
2105 padding: '0 6px', border: 'none', background: 'transparent',
2106 fontFamily: FONT, fontSize: '11.5px', color: BP.text,
2107 outline: 'none',
2108 });
2109 input.disabled = controlsLocked;
2110 if (controlsLocked) {
2111 input.placeholder = 'apply is running...';
2112 input.style.cursor = 'not-allowed';
2113 input.style.opacity = '0.58';
2114 }
2115
2116 const voiceBtn = el('button', {
2117 display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
2118 padding: '0', boxSizing: 'border-box',
2119 width: '28px', height: '28px', flexShrink: '0',
2120 border: 'none', background: 'transparent',
2121 color: BP.textDim, cursor: 'pointer',
2122 });
2123 voiceBtn.id = PREFIX + '-insert-voice';
2124 voiceBtn.type = 'button';
2125 voiceBtn.setAttribute('aria-label', 'Voice input');
2126 voiceBtn.innerHTML = ICON_PAGE_VOICE;
2127 voiceBtn.disabled = controlsLocked;
2128 voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer';
2129 voiceBtn.style.opacity = controlsLocked ? '0.58' : '1';
2130
2131 input.addEventListener('input', () => syncInsertCreateButton());
2132 input.addEventListener('pointerdown', (e) => e.stopPropagation());
2133 input.addEventListener('mousedown', (e) => e.stopPropagation());
2134 input.addEventListener('click', (e) => {
2135 e.stopPropagation();
2136 try { input.focus({ preventScroll: true }); } catch { input.focus(); }
2137 });
2138 input.addEventListener('keydown', (e) => {
2139 if (e.key === 'Enter') {
2140 e.stopPropagation(); e.preventDefault();
2141 if (isInsertCreateEnabled()) handleInsertCreate();
2142 return;
2143 }
2144 if (e.key === 'Escape') {
2145 e.stopPropagation(); e.preventDefault();
2146 cancelInsertConfigure();
2147 return;
2148 }
2149 e.stopPropagation();
2150 });
2151 voiceBtn.addEventListener('mousedown', (e) => e.stopPropagation());
2152 voiceBtn.addEventListener('click', (e) => {
2153 e.stopPropagation();
2154 if (controlsLocked) { showManualApplyBusyToast(); return; }
2155 toggleConfigureVoice();
2156 });
2157
2158 inputWrap.appendChild(input);
2159 inputWrap.appendChild(voiceBtn);
2160 row.appendChild(inputWrap);
2161
2162 const count = el('button', {
2163 display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
2164 boxSizing: 'border-box', height: '28px', padding: '0 6px',
2165 borderRadius: '5px',
2166 border: '1px solid ' + BP.hairline, background: 'transparent',
2167 fontFamily: MONO, fontSize: '11px', fontWeight: '600',
2168 color: BP.textDim, cursor: 'pointer', flexShrink: '0', whiteSpace: 'nowrap',
2169 });
2170 count.textContent = '\u00D7' + selectedCount;
2171 count.disabled = controlsLocked;
2172 count.style.cursor = controlsLocked ? 'not-allowed' : 'pointer';
2173 count.style.opacity = controlsLocked ? '0.58' : '1';
2174 count.addEventListener('click', (e) => {
2175 e.stopPropagation();
2176 if (controlsLocked) { showManualApplyBusyToast(); return; }
2177 selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
2178 count.textContent = '\u00D7' + selectedCount;
2179 });
2180 row.appendChild(count);
2181
2182 const create = el('button', {
2183 display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
2184 boxSizing: 'border-box', height: '28px', padding: '0 12px',
2185 borderRadius: '6px',
2186 border: 'none', background: BP.accent, color: C.ink,
2187 fontFamily: FONT, fontSize: '12px', fontWeight: '600',
2188 flexShrink: '0', whiteSpace: 'nowrap',
2189 });
2190 create.id = PREFIX + '-insert-create';
2191 create.textContent = 'Create \u2192';
2192 create.disabled = controlsLocked;
2193 create.addEventListener('mouseenter', () => {
2194 if (controlsLocked) return;
2195 if (isInsertCreateEnabled(create)) {
2196 hideInsertCreateTooltip();
2197 return;
2198 }
2199 showInsertCreateTooltip(create, insertCreateDisabledReason(insertCreateGateState(input)));
2200 });
2201 create.addEventListener('mouseleave', hideInsertCreateTooltip);
2202 create.addEventListener('click', (e) => {
2203 e.preventDefault();
2204 e.stopPropagation();
2205 if (controlsLocked) { showManualApplyBusyToast(); return; }
2206 if (!isInsertCreateEnabled(create)) return;
2207 handleInsertCreate();
2208 });
2209 row.appendChild(create);
2210 syncInsertCreateButton(create, input);
2211 if (!controlsLocked) setTimeout(() => input.focus(), 60);
2212 return row;
2213 }
2214
2215 // Generating row
2216
2217 function buildGeneratingRow() {
2218 const row = el('div', {
2219 display: 'flex', alignItems: 'center', gap: '8px',
2220 padding: '2px 4px',
2221 });
2222
2223 // Action label
2224 const label = el('span', {
2225 fontWeight: '600', fontSize: '12px', color: BP.text,
2226 flexShrink: '0', whiteSpace: 'nowrap',
2227 });
2228 label.textContent = configureKind === 'insert' ? 'Insert' : actionLabel();
2229 row.appendChild(label);
2230
2231 // Dots
2232 row.appendChild(buildDots(false));
2233
2234 // Status
2235 const status = el('span', {
2236 fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
2237 marginLeft: 'auto',
2238 });
2239 // Variants currently arrive atomically in a single file edit, so a
2240 // per-variant counter would lie. Say what's true.
2241 status.textContent = recoveryWaitingForAnchor
2242 ? 'Variants ready. Reveal the selected element to resume.'
2243 : (arrivedVariants < expectedVariants
2244 ? 'Generating ' + expectedVariants + ' variants...'
2245 : 'Done');
2246 row.appendChild(status);
2247
2248 return row;
2249 }
2250
2251 // Cycling row
2252
2253 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>';
2254
2255 function buildCyclingRow() {
2256 if (!ensureCyclingRenderable('build-cycling-row')) {
2257 return el('div', { display: 'none' });
2258 }
2259 const row = el('div', {
2260 display: 'flex', alignItems: 'center', gap: '6px',
2261 padding: '1px 2px',
2262 });
2263
2264 // Prev
2265 const prev = navBtn('\u2190');
2266 prev.id = PREFIX + '-variant-prev';
2267 prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); });
2268 if (visibleVariant <= 1) prev.style.opacity = '0.3';
2269 row.appendChild(prev);
2270
2271 // Dots (clickable)
2272 row.appendChild(buildDots(true));
2273
2274 // Counter
2275 const counter = el('span', {
2276 fontFamily: MONO, fontSize: '11px', fontWeight: '500',
2277 color: BP.textDim, minWidth: '24px', textAlign: 'center',
2278 });
2279 counter.id = PREFIX + '-variant-counter';
2280 counter.textContent = visibleVariant + '/' + arrivedVariants;
2281 row.appendChild(counter);
2282
2283 // Next
2284 const next = navBtn('\u2192');
2285 next.id = PREFIX + '-variant-next';
2286 next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); });
2287 if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
2288 row.appendChild(next);
2289
2290 // Tune chip - only when the visible variant exposes params
2291 const visParams = parseVariantParams(getVisibleVariantEl());
2292 const hasParams = visParams.length > 0;
2293 if (hasParams) {
2294 const tune = el('button', {
2295 display: 'inline-flex', alignItems: 'center', gap: '6px',
2296 padding: '4px 10px', borderRadius: '5px',
2297 border: '1px solid transparent',
2298 background: tuneOpen ? BP.accentSoft : 'transparent',
2299 color: tuneOpen ? BP.accent : BP.text,
2300 fontFamily: FONT, fontSize: '11px', fontWeight: '500',
2301 cursor: 'pointer',
2302 transition: 'color 0.12s ease, background 0.12s ease',
2303 whiteSpace: 'nowrap',
2304 });
2305 tune.innerHTML = TUNE_ICON_SVG;
2306 const tuneLabel = document.createElement('span');
2307 tuneLabel.textContent = 'Tune';
2308 tune.appendChild(tuneLabel);
2309 const tuneBadge = document.createElement('span');
2310 Object.assign(tuneBadge.style, {
2311 display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
2312 minWidth: '16px', height: '16px', padding: '0 4px',
2313 borderRadius: '999px',
2314 background: tuneOpen ? C.brand : BP.hairline,
2315 color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
2316 fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
2317 lineHeight: '1',
2318 boxSizing: 'border-box',
2319 });
2320 tuneBadge.textContent = String(visParams.length);
2321 tune.appendChild(tuneBadge);
2322 tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
2323 tune.addEventListener('mouseenter', () => {
2324 if (!tuneOpen) tune.style.background = BP.accentSoft;
2325 });
2326 tune.addEventListener('mouseleave', () => {
2327 if (!tuneOpen) tune.style.background = 'transparent';
2328 });
2329 tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
2330 tune.dataset.iceqTune = '1';
2331 row.appendChild(tune);
2332 }
2333
2334 // Spacer
2335 row.appendChild(el('div', { flex: '1' }));
2336
2337 // Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
2338 const accept = el('button', {
2339 padding: '5px 14px', borderRadius: '5px',
2340 border: 'none', background: C.brand, color: C.ink,
2341 fontFamily: FONT, fontSize: '11px', fontWeight: '600',
2342 cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
2343 whiteSpace: 'nowrap',
2344 });
2345 accept.textContent = '\u2713 Accept';
2346 accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
2347 accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
2348 accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
2349 accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
2350 accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
2351 if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
2352 row.appendChild(accept);
2353
2354 // Discard
2355 const discard = el('button', {
2356 padding: '4px 6px', borderRadius: '5px',
2357 border: '1px solid ' + BP.hairline, background: 'transparent',
2358 fontFamily: FONT, fontSize: '11px', color: BP.textDim,
2359 cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
2360 });
2361 discard.textContent = '\u2715';
2362 discard.title = 'Discard all variants';
2363 discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
2364 discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
2365 discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
2366 row.appendChild(discard);
2367
2368 return row;
2369 }
2370
2371 // Shared UI builders
2372
2373 // Saving row (waiting for agent to process accept/discard)
2374
2375 function buildSavingRow() {
2376 const row = el('div', {
2377 display: 'flex', alignItems: 'center', gap: '8px',
2378 padding: '2px 8px',
2379 });
2380 const spinner = el('div', {
2381 width: '14px', height: '14px', borderRadius: '50%',
2382 border: '2px solid ' + BP.hairline,
2383 borderTopColor: BP.accent,
2384 animation: 'impeccable-spin 0.6s linear infinite',
2385 flexShrink: '0',
2386 });
2387 row.appendChild(spinner);
2388 const label = el('span', {
2389 fontSize: '12px', color: BP.textDim, fontWeight: '500',
2390 });
2391 label.textContent = 'Applying variant...';
2392 row.appendChild(label);
2393
2394 ensureSpinKeyframes();
2395 return row;
2396 }
2397
2398 // Confirmed row (green success, auto-dismisses)
2399
2400 function buildConfirmedRow() {
2401 const row = el('div', {
2402 display: 'flex', alignItems: 'center', gap: '8px',
2403 padding: '2px 8px',
2404 });
2405 const check = el('span', {
2406 fontSize: '15px', lineHeight: '1', flexShrink: '0',
2407 color: 'oklch(45% 0.15 145)',
2408 });
2409 check.textContent = '\u2713';
2410 row.appendChild(check);
2411 const label = el('span', {
2412 fontSize: '12px', color: 'oklch(35% 0.1 145)', fontWeight: '600',
2413 });
2414 label.textContent = 'Variant applied';
2415 row.appendChild(label);
2416 return row;
2417 }
2418
2419 // Shared UI builders
2420
2421 function buildDots(clickable) {
2422 const container = el('div', {
2423 display: 'flex', alignItems: 'center', gap: '4px',
2424 });
2425 for (let i = 1; i <= expectedVariants; i++) {
2426 const arrived = i <= arrivedVariants;
2427 const active = i === visibleVariant;
2428 // active: solid site-brand kinpaku dot. arrived+inactive: muted neutral.
2429 // pending (not yet arrived): faint outline ring. No borders on arrived
2430 // dots - the previous "accent ring + ash fill" combo read as noisy
2431 // kinpaku chips, especially when all variants had arrived and every
2432 // dot wore an accent ring.
2433 const dotBg = active ? C.brand
2434 : arrived ? BP.textDim
2435 : 'transparent';
2436 const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
2437 const dot = el('div', {
2438 width: active ? '8px' : '6px',
2439 height: active ? '8px' : '6px',
2440 borderRadius: '50%',
2441 background: dotBg,
2442 border: dotBorder,
2443 boxSizing: 'border-box',
2444 transition: 'all 0.2s ' + EASE,
2445 cursor: (clickable && arrived) ? 'pointer' : 'default',
2446 transform: arrived ? 'scale(1)' : 'scale(0.85)',
2447 opacity: arrived ? (active ? '1' : '0.6') : '0.4',
2448 });
2449 if (clickable && arrived) {
2450 const idx = i;
2451 dot.addEventListener('click', (e) => {
2452 e.stopPropagation();
2453 selectVariant(idx, 'variant_changed');
2454 });
2455 }
2456 container.appendChild(dot);
2457 }
2458 return container;
2459 }
2460
2461 function navBtn(text) {
2462 const b = el('button', {
2463 width: '26px', height: '26px', borderRadius: '5px',
2464 border: '1px solid ' + BP.hairline, background: 'transparent',
2465 color: BP.text, fontFamily: FONT, fontSize: '13px',
2466 cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
2467 transition: 'border-color 0.12s ease, background 0.12s ease',
2468 padding: '0', lineHeight: '1',
2469 });
2470 b.textContent = text;
2471 b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
2472 b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
2473 return b;
2474 }
2475
2476 function actionLabel() {
2477 const a = ACTIONS.find(a => a.value === selectedAction);
2478 return a ? a.label : 'Freeform';
2479 }
2480
2481 function el(tag, styles) {
2482 const e = document.createElement(tag);
2483 if (String(tag).toLowerCase() === 'button') e.type = 'button';
2484 if (styles) Object.assign(e.style, styles);
2485 return e;
2486 }
2487
2488 //
2489 // Action picker popover
2490 //
2491
2492 function initActionPicker() {
2493 const P = barPaletteForTheme(detectPageTheme());
2494 pickerEl = document.createElement('div');
2495 pickerEl.id = PREFIX + '-picker';
2496 Object.assign(pickerEl.style, {
2497 position: 'fixed', zIndex: Z.picker,
2498 display: 'none', opacity: '0',
2499 transform: 'scale(0.96) translateY(4px)',
2500 transformOrigin: 'bottom left',
2501 transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
2502 background: P.surface,
2503 border: '1px solid ' + P.border,
2504 borderRadius: '8px',
2505 boxShadow: P.shadow,
2506 padding: '6px',
2507 fontFamily: FONT,
2508 });
2509
2510 // Build the chip grid
2511 const grid = el('div', {
2512 display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '3px',
2513 });
2514
2515 ACTIONS.forEach(action => {
2516 const chip = el('button', {
2517 display: 'flex', flexDirection: 'column', alignItems: 'center',
2518 gap: '4px',
2519 padding: '8px 6px', borderRadius: '6px',
2520 border: 'none',
2521 background: action.value === selectedAction ? P.accentSoft : 'transparent',
2522 color: action.value === selectedAction ? P.accent : P.text,
2523 fontFamily: FONT, fontSize: '11px', fontWeight: '500',
2524 cursor: 'pointer',
2525 transition: 'background 0.1s ease, color 0.1s ease',
2526 textAlign: 'center', whiteSpace: 'nowrap',
2527 });
2528 const iconWrap = el('span', {
2529 display: 'flex', alignItems: 'center', justifyContent: 'center',
2530 height: '20px', opacity: '0.9',
2531 });
2532 iconWrap.innerHTML = ICONS[action.value] || '';
2533 const labelEl = el('span', { lineHeight: '1' });
2534 labelEl.textContent = action.label;
2535 chip.appendChild(iconWrap);
2536 chip.appendChild(labelEl);
2537 chip.dataset.action = action.value;
2538 chip.addEventListener('mouseenter', () => {
2539 if (action.value !== selectedAction) chip.style.background = P.accentSoft;
2540 });
2541 chip.addEventListener('mouseleave', () => {
2542 chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
2543 });
2544 chip.addEventListener('click', (e) => {
2545 e.preventDefault();
2546 e.stopPropagation();
2547 const prompt = uiGetById(PREFIX + '-input')?.value || '';
2548 selectedAction = action.value;
2549 hideActionPicker();
2550 updateBarContent('configure');
2551 const input = uiGetById(PREFIX + '-input');
2552 if (input && prompt) input.value = prompt;
2553 });
2554 grid.appendChild(chip);
2555 });
2556
2557 pickerEl.appendChild(grid);
2558 uiAppend(pickerEl);
2559 defangOutsideHandlers(pickerEl);
2560
2561 // Cache the palette on the picker so toggleActionPicker's state refresh
2562 // uses the same theme-aware colors when it repaints chips.
2563 pickerEl.__iceq_palette = P;
2564 }
2565
2566 function toggleActionPicker() {
2567 if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
2568 if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
2569 // Rebuild chips to reflect current selection
2570 const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
2571 pickerEl.querySelectorAll('button').forEach(chip => {
2572 const isActive = chip.dataset.action === selectedAction;
2573 chip.style.background = isActive ? P.accentSoft : 'transparent';
2574 chip.style.color = isActive ? P.accent : P.text;
2575 });
2576 // Position above the bar
2577 const barRect = barEl.getBoundingClientRect();
2578 const pickerH = 170; // approximate; grows with icon + label rows
2579 let top = barRect.top - pickerH - 6;
2580 if (top < 8) top = barRect.bottom + 6;
2581 Object.assign(pickerEl.style, {
2582 top: top + 'px', left: barRect.left + 'px',
2583 display: 'block',
2584 });
2585 requestAnimationFrame(() => {
2586 pickerEl.style.opacity = '1';
2587 pickerEl.style.transform = 'scale(1) translateY(0)';
2588 });
2589 }
2590
2591 function hideActionPicker() {
2592 if (!pickerEl) return;
2593 pickerEl.style.opacity = '0';
2594 pickerEl.style.transform = 'scale(0.96) translateY(4px)';
2595 setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
2596 }
2597
2598 function ensureCyclingRenderable(reason) {
2599 if (arrivedVariants > 0) {
2600 if (visibleVariant < 1 || visibleVariant > arrivedVariants) visibleVariant = 1;
2601 return true;
2602 }
2603 recoverEmptyCycling(reason);
2604 return false;
2605 }
2606
2607 function recoverEmptyCycling(reason) {
2608 if (recoveringEmptyCycling) return;
2609 recoveringEmptyCycling = true;
2610 try {
2611 console.warn('[impeccable] Refusing to render empty variant cycling state:', reason);
2612 const message = 'No variants were mounted. Please try again.';
2613 if (svelteComponentSession?.sessionId === currentSessionId) {
2614 abortSvelteComponentInjection(currentSessionId, message);
2615 return;
2616 }
2617 cleanup();
2618 showToast(message, 5000);
2619 } finally {
2620 recoveringEmptyCycling = false;
2621 }
2622 }
2623
2624 //
2625 // Params panel (per-variant coarse controls)
2626 //
2627 // Variants may declare a parameter manifest via a JSON attribute on the
2628 // variant wrapper:
2629 //
2630 // <div data-impeccable-variant="1"
2631 // data-impeccable-params='[{"id":"density","kind":"steps",...}]'>
2632 //
2633 // The panel docks to the right edge of the outline during CYCLING and
2634 // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
2635 // CSS can respond instantly without regeneration:
2636 //
2637 // range / numeric toggle -> CSS custom property used by variant styles
2638 // steps / boolean toggle → data-p-<id> attribute used via :scope[data-p-foo="..."]
2639 //
2640 // On variant switch, values reset to that variant's declared defaults.
2641 // On accept, current values are sent in the event payload so the agent
2642 // can bake them into the source-file write.
2643 //
2644
2645 let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide)
2646 let paramsPanelInner = null; // translating content (carries bg, padding, knobs)
2647 let paramsPanelBody = null; // grid holding the knob cells
2648 let paramsCurrentValues = {}; // {paramId: value} - mirror of the visible variant's live values
2649 let tuneOpen = false; // whether the Tune popover is open right now
2650
2651 // Theme-aware Tune popover. Appears as a drawer that slides out from the
2652 // contextual bar's bar-facing edge (below if the bar sits below the
2653 // element, above otherwise). Same width as the bar. Auto-wraps to extra
2654 // rows when the knobs exceed one row. The bar's border-radius on the
2655 // popover side goes flat while open so the two shapes read as one.
2656 let paramsPanelPalette = null;
2657
2658 function initParamsPanel() {
2659 paramsPanelPalette = barPaletteForTheme(detectPageTheme());
2660 const P = paramsPanelPalette;
2661
2662 // Single element, always in the DOM. The slide animation is a CSS mask
2663 // with mask-size growing from 0% to 100% along the bar-facing axis - no
2664 // display toggle, no opacity toggle, no transform trickery. The mask
2665 // hides everything initially; as it grows, content is revealed from
2666 // the bar edge outward.
2667 paramsPanelEl = document.createElement('div');
2668 paramsPanelEl.id = PREFIX + '-params-panel';
2669 Object.assign(paramsPanelEl.style, {
2670 position: 'fixed', zIndex: String(Z.bar - 1),
2671 background: P.surfaceDeep,
2672 color: P.text,
2673 fontFamily: FONT,
2674 padding: '14px 18px',
2675 boxSizing: 'border-box',
2676 borderRadius: '0 0 10px 10px',
2677 pointerEvents: 'none',
2678
2679 // clip-path is the same conceptual reveal as mask but with rock-solid
2680 // transition support across engines. Closed state clips from the far
2681 // edge; open = inset(0) shows everything.
2682 clipPath: 'inset(0 0 100% 0)',
2683 transition: 'clip-path 0.44s ' + EASE,
2684
2685 // Park off-screen until positionParamsPanel places it. These are NOT
2686 // in the transition list, so they snap instantly - no fly-in from the
2687 // top-left when first shown.
2688 top: '-9999px', left: '-9999px', width: '0',
2689 });
2690
2691 paramsPanelBody = el('div', {
2692 display: 'grid',
2693 gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
2694 gap: '12px 16px',
2695 });
2696
2697 paramsPanelEl.appendChild(paramsPanelBody);
2698 uiAppend(paramsPanelEl);
2699 // Don't override pointer-events: the panel toggles between 'none' (closed,
2700 // click-through) and 'auto' (open) on its own. Just silence the host's
2701 // outside-interaction listeners while the panel is open.
2702 defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
2703 paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
2704 }
2705
2706
2707 function getMountedSvelteComponentAnchor(session = svelteComponentSession) {
2708 const el = session?.mountTargetEl?.firstElementChild || null;
2709 if (!el || !document.body.contains(el)) return null;
2710 return rectIsUsableAnchor(el.getBoundingClientRect()) ? el : null;
2711 }
2712
2713 function resolveSvelteComponentAnchor(session = svelteComponentSession) {
2714 return getMountedSvelteComponentAnchor(session)
2715 || session?.swapAnchor
2716 || null;
2717 }
2718
2719 function getVisibleVariantEl() {
2720 if (!currentSessionId) return null;
2721 if (svelteComponentSession?.sessionId === currentSessionId) {
2722 return resolveSvelteComponentAnchor()
2723 || svelteComponentSession.wrapperEl
2724 || null;
2725 }
2726 const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
2727 if (!wrapper) return null;
2728 return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
2729 }
2730
2731 function parseVariantParams(variantEl) {
2732 // Svelte component variants can't carry a `data-impeccable-params` attribute:
2733 // the compiler reads `{` inside attribute values as expression delimiters, so
2734 // JSON-with-braces breaks the build. For that path the params live in a sidecar
2735 // params.json keyed by variant number, loaded into the session at mount time.
2736 if (svelteComponentSession?.sessionId === currentSessionId) {
2737 const byVariant = svelteComponentSession.paramsByVariant || {};
2738 const params = byVariant[String(visibleVariant)] || byVariant[visibleVariant];
2739 return Array.isArray(params) ? params : [];
2740 }
2741 if (!variantEl) return [];
2742 const raw = variantEl.getAttribute('data-impeccable-params');
2743 if (!raw) return [];
2744 try {
2745 const parsed = JSON.parse(raw);
2746 return Array.isArray(parsed) ? parsed : [];
2747 } catch (err) {
2748 console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
2749 return [];
2750 }
2751 }
2752
2753 function applyParamValue(variantEl, param, value) {
2754 if (!variantEl) return;
2755 const attr = 'data-p-' + param.id;
2756 if (param.kind === 'range') {
2757 variantEl.style.setProperty('--p-' + param.id, String(value));
2758 } else if (param.kind === 'toggle') {
2759 const on = !!value;
2760 variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
2761 if (on) variantEl.setAttribute(attr, 'on');
2762 else variantEl.removeAttribute(attr);
2763 } else if (param.kind === 'steps') {
2764 variantEl.setAttribute(attr, String(value));
2765 }
2766 }
2767
2768 function applyParamDefaults(variantEl, params) {
2769 paramsCurrentValues = {};
2770 for (const p of params) {
2771 paramsCurrentValues[p.id] = p.default;
2772 applyParamValue(variantEl, p, p.default);
2773 }
2774 }
2775
2776 function formatRangeValue(input) {
2777 const max = parseFloat(input.max), min = parseFloat(input.min);
2778 const v = parseFloat(input.value);
2779 if (!isFinite(v)) return input.value;
2780 return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
2781 }
2782
2783 function buildParamsPanel(variantEl, params) {
2784 const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
2785 paramsPanelBody.innerHTML = '';
2786 for (const p of params) {
2787 const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
2788 const labelRow = el('div', {
2789 display: 'flex', justifyContent: 'space-between',
2790 alignItems: 'baseline', gap: '8px',
2791 });
2792 const lbl = el('span', {
2793 fontSize: '10.5px', fontWeight: '600', color: P.text,
2794 letterSpacing: '0.03em',
2795 });
2796 lbl.textContent = p.label || p.id;
2797 labelRow.appendChild(lbl);
2798 const readout = el('span', {
2799 fontSize: '10.5px', color: P.textDim,
2800 fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
2801 });
2802 labelRow.appendChild(readout);
2803 row.appendChild(labelRow);
2804
2805 if (p.kind === 'range') {
2806 const input = document.createElement('input');
2807 input.type = 'range';
2808 input.min = String(p.min != null ? p.min : 0);
2809 input.max = String(p.max != null ? p.max : 1);
2810 input.step = String(p.step != null ? p.step : 0.05);
2811 input.value = String(p.default);
2812 Object.assign(input.style, {
2813 width: '100%', accentColor: C.brand, cursor: 'pointer',
2814 });
2815 readout.textContent = formatRangeValue(input);
2816 input.addEventListener('input', (e) => {
2817 e.stopPropagation();
2818 const v = parseFloat(input.value);
2819 paramsCurrentValues[p.id] = v;
2820 readout.textContent = formatRangeValue(input);
2821 applyParamValue(variantEl, p, v);
2822 queueCheckpoint('param_changed');
2823 });
2824 row.appendChild(input);
2825 } else if (p.kind === 'toggle') {
2826 const initial = !!p.default;
2827 readout.textContent = initial ? 'On' : 'Off';
2828 const track = el('button', {
2829 position: 'relative', width: '36px', height: '20px',
2830 borderRadius: '10px', border: 'none', padding: '0',
2831 cursor: 'pointer',
2832 background: initial ? C.brand : P.hairline,
2833 transition: 'background 0.15s ease',
2834 alignSelf: 'flex-start',
2835 });
2836 const knob = el('span', {
2837 position: 'absolute', top: '2px',
2838 left: initial ? '18px' : '2px',
2839 width: '16px', height: '16px', borderRadius: '50%',
2840 background: 'oklch(98% 0 0)',
2841 transition: 'left 0.18s ' + EASE,
2842 boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
2843 });
2844 track.appendChild(knob);
2845 track.addEventListener('click', (e) => {
2846 e.stopPropagation();
2847 const next = !paramsCurrentValues[p.id];
2848 paramsCurrentValues[p.id] = next;
2849 track.style.background = next ? C.brand : P.hairline;
2850 knob.style.left = next ? '18px' : '2px';
2851 readout.textContent = next ? 'On' : 'Off';
2852 applyParamValue(variantEl, p, next);
2853 queueCheckpoint('param_changed');
2854 });
2855 row.appendChild(track);
2856 } else if (p.kind === 'steps') {
2857 const opts = (p.options || []).map(o =>
2858 typeof o === 'string' ? { value: o, label: o } : o
2859 );
2860 const activeOpt = opts.find(o => o.value === p.default) || opts[0];
2861 readout.textContent = activeOpt ? activeOpt.label : String(p.default);
2862 const segRow = el('div', {
2863 display: 'grid',
2864 gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
2865 gap: '1px', padding: '2px',
2866 background: P.hairline, borderRadius: '5px',
2867 });
2868 const segBtns = [];
2869 opts.forEach(o => {
2870 const active = o.value === p.default;
2871 const b = el('button', {
2872 padding: '5px 4px', border: 'none', borderRadius: '3px',
2873 background: active ? C.brand : 'transparent',
2874 color: active ? 'oklch(98% 0 0)' : P.text,
2875 fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
2876 cursor: 'pointer', whiteSpace: 'nowrap',
2877 transition: 'background 0.1s ease, color 0.1s ease',
2878 });
2879 b.textContent = o.label;
2880 b.addEventListener('click', (e) => {
2881 e.stopPropagation();
2882 paramsCurrentValues[p.id] = o.value;
2883 readout.textContent = o.label;
2884 segBtns.forEach(({ btn, val }) => {
2885 const on = val === o.value;
2886 btn.style.background = on ? C.brand : 'transparent';
2887 btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
2888 });
2889 applyParamValue(variantEl, p, o.value);
2890 queueCheckpoint('param_changed');
2891 });
2892 segRow.appendChild(b);
2893 segBtns.push({ btn: b, val: o.value });
2894 });
2895 row.appendChild(segRow);
2896 }
2897
2898 paramsPanelBody.appendChild(row);
2899 }
2900 }
2901
2902 //
2903 // Inline text editing - makes pure-text descendants of the picked element
2904 // directly contenteditable. Save stages copy edits in the live buffer; the
2905 // Apply copy edits dock later asks the AI to apply the staged batch.
2906 //
2907
2908 let inlineEditRows = [];
2909 let inlineEditDrafts = new Map();
2910
2911 // Mixed-content elements (e.g. <p>text<code>x</code>text</p>) skip the row
2912 // walker's "all-children-are-text-nodes" rule. Wrap each non-whitespace direct
2913 // text-node child in a marker span so the walker emits a row for it. The
2914 // wrappers are inline display by default and inherit styles, so the page
2915 // shouldn't visually shift. We unwrap in disableInlineEdit.
2916 const MIXED_WRAP_SKIP = { script: 1, style: 1, template: 1, noscript: 1, svg: 1, code: 1, pre: 1 };
2917
2918 function collectEditableTextRows(rootEl, opts) {
2919 if (!rootEl || rootEl.nodeType !== 1) return [];
2920 const isOwn = (opts && opts.isOwn) || (() => false);
2921 const rows = [];
2922
2923 function visit(el) {
2924 if (!el || el.nodeType !== 1) return;
2925 const tag = el.tagName.toLowerCase();
2926 if (MIXED_WRAP_SKIP[tag]) return;
2927 if (el.hasAttribute && el.hasAttribute('contenteditable')) return;
2928 if (el !== rootEl && isOwn(el)) return;
2929
2930 const children = Array.from(el.childNodes);
2931 const textNodes = [];
2932 let allText = children.length > 0;
2933 let hasNonWhitespaceText = false;
2934 for (const node of children) {
2935 if (node.nodeType === 3) {
2936 textNodes.push(node);
2937 if (node.nodeValue && /\S/.test(node.nodeValue)) hasNonWhitespaceText = true;
2938 } else {
2939 allText = false;
2940 }
2941 }
2942 if (allText && hasNonWhitespaceText) {
2943 rows.push({
2944 el,
2945 ref: documentRefForElement(el) || el.tagName.toLowerCase(),
2946 text: textNodes.map((node) => node.nodeValue).join(''),
2947 textNodes,
2948 });
2949 }
2950
2951 for (const child of children) {
2952 if (child.nodeType === 1) visit(child);
2953 }
2954 }
2955
2956 visit(rootEl);
2957 return rows;
2958 }
2959
2960 function wrapMixedContentTextNodes(rootEl) {
2961 if (!rootEl || rootEl.nodeType !== 1) return;
2962 const tag = rootEl.tagName.toLowerCase();
2963 if (MIXED_WRAP_SKIP[tag]) return;
2964 if (rootEl.hasAttribute('contenteditable')) return;
2965 const children = Array.from(rootEl.childNodes);
2966 const hasText = children.some((n) => n.nodeType === 3 && /\S/.test(n.nodeValue || ''));
2967 const hasElement = children.some((n) => n.nodeType === 1);
2968 if (hasText && hasElement) {
2969 for (const node of children) {
2970 if (node.nodeType === 3 && /\S/.test(node.nodeValue || '')) {
2971 const wrap = document.createElement('span');
2972 wrap.dataset.impeccableTextWrap = 'true';
2973 wrap.textContent = node.nodeValue;
2974 rootEl.insertBefore(wrap, node);
2975 rootEl.removeChild(node);
2976 }
2977 }
2978 }
2979 for (const child of Array.from(rootEl.children)) {
2980 if (!child.dataset || !child.dataset.impeccableTextWrap) {
2981 wrapMixedContentTextNodes(child);
2982 }
2983 }
2984 }
2985 function unwrapMixedContentTextNodes(rootEl) {
2986 if (!rootEl || rootEl.nodeType !== 1) return;
2987 const wraps = rootEl.querySelectorAll('[data-impeccable-text-wrap="true"]');
2988 for (const wrap of wraps) {
2989 const parent = wrap.parentNode;
2990 if (!parent) continue;
2991 const textNode = document.createTextNode(wrap.textContent);
2992 parent.replaceChild(textNode, wrap);
2993 parent.normalize();
2994 }
2995 }
2996 let inlineEditRoot = null;
2997
2998 function enableInlineEdit(targetEl) {
2999 if (!targetEl) return;
3000 inlineEditRoot = targetEl;
3001 wrapMixedContentTextNodes(targetEl);
3002 const rows = collectEditableTextRows(targetEl, { isOwn: own });
3003 inlineEditRows = rows;
3004 inlineEditDrafts = new Map();
3005 for (const row of rows) {
3006 row.inlineWhiteSpace = row.el.style.whiteSpace;
3007 row.el.style.whiteSpace = getComputedStyle(row.el).whiteSpace;
3008 row.el.setAttribute('contenteditable', 'true');
3009 row.el.dataset.impeccableEditable = 'true';
3010 row.el.dataset.impeccableOriginalText = row.text;
3011 row.el.style.userSelect = 'text';
3012 row.el.style.cursor = 'text';
3013 row.el.style.outline = 'none';
3014 row.el.addEventListener('input', onInlineInput);
3015 }
3016 }
3017
3018 function disableInlineEdit(opts = {}) {
3019 for (const row of inlineEditRows) {
3020 if (activeElementDeep() === row.el) row.el.blur();
3021 row.el.removeAttribute('contenteditable');
3022 delete row.el.dataset.impeccableEditable;
3023 delete row.el.dataset.impeccableOriginalText;
3024 row.el.style.whiteSpace = row.inlineWhiteSpace || '';
3025 row.el.style.userSelect = '';
3026 row.el.style.cursor = '';
3027 row.el.style.outline = '';
3028 row.el.removeEventListener('input', onInlineInput);
3029 }
3030 inlineEditRows = [];
3031 inlineEditDrafts = new Map();
3032 if (inlineEditRoot && !opts.preserveMixedWraps) {
3033 unwrapMixedContentTextNodes(inlineEditRoot);
3034 inlineEditRoot = null;
3035 }
3036 }
3037
3038 function onInlineInput(e) {
3039 inlineEditDrafts.set(e.currentTarget, e.currentTarget.textContent);
3040 }
3041
3042 function hasTextRows(el) {
3043 if (!el) return false;
3044 // Lightweight: any descendant outside SKIP_SUBTREE_TAGS with at least one
3045 // non-whitespace direct text-node child means we have something editable
3046 // (mixed-content paragraphs included). Mirrors what the wrap+walk path
3047 // will produce in enableInlineEdit.
3048 function check(node) {
3049 if (!node || node.nodeType !== 1) return false;
3050 const tag = node.tagName.toLowerCase();
3051 if (MIXED_WRAP_SKIP[tag]) return false;
3052 if (node !== el && own(node)) return false;
3053 for (const child of node.childNodes) {
3054 if (child.nodeType === 3 && /\S/.test(child.nodeValue || '')) return true;
3055 }
3056 for (const child of node.children) {
3057 if (check(child)) return true;
3058 }
3059 return false;
3060 }
3061 return check(el);
3062 }
3063
3064 function enterEditingMode() {
3065 if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
3066 state = 'EDITING';
3067 hideBar();
3068 hideAnnotOverlay();
3069 renderEditBadge('editing');
3070 enableInlineEdit(selectedElement);
3071 // Focus first editable element and position cursor at end
3072 if (inlineEditRows.length > 0) {
3073 const firstEditable = inlineEditRows[0] && inlineEditRows[0].el;
3074 setTimeout(() => {
3075 const el = firstEditable;
3076 if (!el || !el.isConnected || state !== 'EDITING') return;
3077 el.focus();
3078 const range = document.createRange();
3079 const sel = window.getSelection();
3080 range.selectNodeContents(el);
3081 range.collapse(false);
3082 sel.removeAllRanges();
3083 sel.addRange(range);
3084 }, 50);
3085 }
3086 }
3087
3088 function restoreInlineEditDrafts() {
3089 for (const row of inlineEditRows) {
3090 if (inlineEditDrafts.has(row.el)) {
3091 row.el.textContent = row.el.dataset.impeccableOriginalText;
3092 }
3093 }
3094 }
3095
3096 function cancelEditing() {
3097 restoreInlineEditDrafts();
3098 disableInlineEdit();
3099 state = 'CONFIGURING';
3100 showBar('configure');
3101 showAnnotOverlay(selectedElement);
3102 renderEditBadge('idle');
3103 }
3104
3105 function cancelEditingToPicking() {
3106 restoreInlineEditDrafts();
3107 disableInlineEdit();
3108 hideBar();
3109 stopScrollTracking();
3110 hideAnnotOverlay();
3111 clearAnnotations();
3112 renderEditBadge('hidden');
3113 state = 'PICKING';
3114 hoveredElement = null;
3115 hideHighlight();
3116 syncPageChatFocus('editing-outside-click');
3117 }
3118
3119 // Prefer the leaf's own id/class; if it has neither (e.g. a bare <em>),
3120 // climb to the nearest ancestor with one. The CLI uses tag+class together,
3121 // so tag must come from the same node as the locator.
3122 function buildLocatorForLeaf(leafEl, fallbackEl) {
3123 if (leafEl && (leafEl.id || leafEl.classList.length > 0)) {
3124 return {
3125 tag: leafEl.tagName.toLowerCase(),
3126 elementId: leafEl.id || null,
3127 classes: [...leafEl.classList],
3128 };
3129 }
3130 let cur = leafEl?.parentElement;
3131 while (cur && cur !== document.body) {
3132 if (cur.id || cur.classList.length > 0) {
3133 return {
3134 tag: cur.tagName.toLowerCase(),
3135 elementId: cur.id || null,
3136 classes: [...cur.classList],
3137 };
3138 }
3139 cur = cur.parentElement;
3140 }
3141 return {
3142 tag: (fallbackEl || leafEl).tagName.toLowerCase(),
3143 elementId: (fallbackEl || leafEl).id || null,
3144 classes: [...((fallbackEl || leafEl).classList || [])],
3145 };
3146 }
3147
3148 function sourceHintForElement(el) {
3149 if (!el || !el.getAttribute) return null;
3150 const file = el.getAttribute('data-astro-source-file');
3151 const loc = el.getAttribute('data-astro-source-loc');
3152 if (file || loc) {
3153 const parsed = parseSourceLoc(loc);
3154 return {
3155 file: file || '',
3156 loc: loc || '',
3157 line: parsed.line,
3158 column: parsed.column,
3159 };
3160 }
3161 return null;
3162 }
3163
3164 function parseSourceLoc(loc) {
3165 const match = String(loc || '').match(/^(\d+)(?::(\d+))?/);
3166 return {
3167 line: match ? Number(match[1]) : null,
3168 column: match && match[2] ? Number(match[2]) : null,
3169 };
3170 }
3171
3172 function documentRefForElement(el) {
3173 if (!el || el.nodeType !== 1) return null;
3174 const parts = [];
3175 let cur = el;
3176 while (cur && cur.nodeType === 1) {
3177 const tag = cur.tagName.toLowerCase();
3178 if (tag === 'html') break;
3179 if (tag === 'body') {
3180 parts.unshift('body');
3181 break;
3182 }
3183 parts.unshift(documentRefSegment(cur));
3184 cur = cur.parentElement;
3185 }
3186 return parts.join('>') || null;
3187 }
3188
3189 function documentRefSegment(el) {
3190 const tag = el.tagName.toLowerCase();
3191 return tag + documentRefIdSuffix(el) + documentRefClassSuffix(el) + ':nth-of-type(' + indexAmongSameTag(el) + ')';
3192 }
3193
3194 function documentRefIdSuffix(el) {
3195 return el.id ? '#' + normalizeDocumentRefToken(el.id) : '';
3196 }
3197
3198 function documentRefClassSuffix(el) {
3199 if (!el.classList || el.classList.length === 0) return '';
3200 const classes = [];
3201 for (const cls of el.classList) {
3202 if (!cls || cls.indexOf('impeccable-') === 0) continue;
3203 classes.push(normalizeDocumentRefToken(cls));
3204 if (classes.length === 2) break;
3205 }
3206 return classes.length ? '.' + classes.join('.') : '';
3207 }
3208
3209 function normalizeDocumentRefToken(value) {
3210 return String(value || '').replace(/[>\s]+/g, '_');
3211 }
3212
3213 function indexAmongSameTag(el) {
3214 const parent = el.parentElement;
3215 if (!parent) return 1;
3216 const tag = el.tagName.toLowerCase();
3217 let n = 0;
3218 for (const sib of parent.children) {
3219 if (sib.tagName.toLowerCase() === tag) {
3220 n++;
3221 if (sib === el) return n;
3222 }
3223 }
3224 return 1;
3225 }
3226
3227 function copyEditLeafContext(el, originalText, newText) {
3228 if (!el) return null;
3229 return {
3230 ref: documentRefForElement(el),
3231 tagName: el.tagName ? el.tagName.toLowerCase() : null,
3232 id: el.id || null,
3233 classes: el.classList ? [...el.classList].filter((cls) => cls.indexOf('impeccable-') !== 0) : [],
3234 originalText,
3235 newText,
3236 textContent: (el.textContent || '').slice(0, 500),
3237 outerHTML: sanitizedContextOuterHTML(el, 3000) || null,
3238 };
3239 }
3240
3241 function nearbyEditableTextsForManualEdit(rows, activeEl, originalText, newText) {
3242 const out = [];
3243 const seen = new Set();
3244 const skip = new Set([normalizeManualContextText(originalText), normalizeManualContextText(newText)]);
3245 for (const row of rows || []) {
3246 if (!row || row.el === activeEl) continue;
3247 const text = normalizeManualContextText(row.text);
3248 if (!text || text.length < 2 || seen.has(text) || skip.has(text)) continue;
3249 seen.add(text);
3250 out.push({
3251 ref: documentRefForElement(row.el),
3252 tag: row.el?.tagName ? row.el.tagName.toLowerCase() : null,
3253 classes: row.el?.classList ? [...row.el.classList].filter((cls) => cls.indexOf('impeccable-') !== 0) : [],
3254 text,
3255 });
3256 if (out.length >= 12) break;
3257 }
3258 return out;
3259 }
3260
3261 function copyEditContainerContext(el) {
3262 if (!el) return null;
3263 return {
3264 ref: documentRefForElement(el),
3265 tagName: el.tagName ? el.tagName.toLowerCase() : null,
3266 id: el.id || null,
3267 classes: el.classList ? [...el.classList].filter((cls) => cls.indexOf('impeccable-') !== 0) : [],
3268 textContent: (el.textContent || '').slice(0, 1000),
3269 outerHTML: sanitizedContextOuterHTML(el, 10000) || null,
3270 };
3271 }
3272
3273 function forbiddenManualTextChars(text) {
3274 const out = [];
3275 for (const ch of ['<', '{', '}', '`']) {
3276 if (String(text || '').includes(ch)) out.push(ch);
3277 }
3278 return out;
3279 }
3280
3281 async function applyEditing() {
3282 if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
3283 const ops = [];
3284 for (const row of inlineEditRows) {
3285 const newText = inlineEditDrafts.get(row.el);
3286 if (newText !== undefined && newText !== row.text) {
3287 if (String(newText || '').trim() === '') {
3288 showToast('Save rejected: copy edits cannot be empty.', 5500);
3289 return;
3290 }
3291 const forbidden = forbiddenManualTextChars(newText);
3292 if (forbidden.length > 0) {
3293 showToast('Save rejected: newText cannot contain ' + forbidden.join(' ') + ' (plain text only; ask the AI to insert markup)', 5500);
3294 return;
3295 }
3296 const locator = buildLocatorForLeaf(row.el, selectedElement);
3297 const op = {
3298 ref: row.ref,
3299 tag: locator.tag,
3300 elementId: locator.elementId,
3301 classes: locator.classes,
3302 originalText: row.text,
3303 newText,
3304 };
3305 op.leaf = copyEditLeafContext(row.el, row.text, newText);
3306 op.nearbyEditableTexts = nearbyEditableTextsForManualEdit(inlineEditRows, row.el, row.text, newText);
3307 const restoreHint = mixedTextWrapRestoreHint(row.el);
3308 if (restoreHint) op.restore = restoreHint;
3309 const sourceHint = sourceHintForElement(row.el);
3310 if (sourceHint) op.sourceHint = sourceHint;
3311 ops.push(op);
3312 }
3313 }
3314 if (ops.length === 0) { cancelEditing(); return; }
3315 const contextElement = contextElementForManualEdit(selectedElement, inlineEditRows, ops);
3316 const contextRef = documentRefForElement(contextElement);
3317 if (contextRef) for (const op of ops) op.contextRef = contextRef;
3318 const container = copyEditContainerContext(contextElement);
3319 if (container) for (const op of ops) op.container = container;
3320 try {
3321 const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', {
3322 method: 'POST',
3323 headers: { 'Content-Type': 'application/json' },
3324 body: JSON.stringify({
3325 token: TOKEN,
3326 id: id8(),
3327 pageUrl: location.pathname,
3328 element: extractContext(contextElement),
3329 ops,
3330 }),
3331 });
3332 if (!res.ok) {
3333 const errBody = await res.json().catch(() => ({}));
3334 throw new Error(errBody.error || ('HTTP ' + res.status));
3335 }
3336 const stashResult = await res.json();
3337 updatePendingCounter(stashResult.pendingCount || 0);
3338 maybeShowFirstSaveToast();
3339 disableInlineEdit();
3340 state = 'CONFIGURING';
3341 showBar('configure');
3342 showAnnotOverlay(selectedElement);
3343 renderEditBadge('idle');
3344 } catch (err) {
3345 console.error('[impeccable] manual edit stash failed:', err);
3346 const detail = String(err?.message || '');
3347 if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) {
3348 showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500);
3349 } else {
3350 showToast('Save failed - retry or cancel', 4000);
3351 }
3352 }
3353 }
3354
3355 function schedulePendingDockPosition() {
3356 if (!pendingDockEl || !globalBarEl) return;
3357 requestAnimationFrame(positionPendingDock);
3358 }
3359
3360 function positionPendingDock() {
3361 if (!pendingDockEl || !globalBarEl) return;
3362 const width = globalBarEl.offsetWidth;
3363 const height = globalBarEl.offsetHeight;
3364 if (!width || !height) return;
3365 pendingDockEl.style.left = Math.round((window.innerWidth / 2) - (width / 2) - 18) + 'px';
3366 pendingDockEl.style.top = 'auto';
3367 pendingDockEl.style.bottom = Math.round(14 + (height / 2)) + 'px';
3368 }
3369
3370 function playPendingIntroAnimation() {
3371 if (!pendingPillEl || !pendingPillEl.animate || (matchMedia?.('(prefers-reduced-motion: reduce)').matches)) return;
3372 if (pendingIntroAnimation) pendingIntroAnimation.cancel();
3373 pendingIntroAnimation = pendingPillEl.animate([
3374 {
3375 opacity: 0,
3376 transform: 'scale(0.82)',
3377 filter: 'brightness(1.2)',
3378 boxShadow: '0 0 0 0 oklch(84% 0.19 80.46 / 0.45), 0 8px 24px oklch(0% 0 0 / 0.16)',
3379 },
3380 {
3381 opacity: 1,
3382 transform: 'scale(1.08)',
3383 filter: 'brightness(1.15)',
3384 boxShadow: '0 0 0 12px oklch(84% 0.19 80.46 / 0), 0 12px 34px oklch(0% 0 0 / 0.22)',
3385 offset: 0.55,
3386 },
3387 {
3388 opacity: 1,
3389 transform: 'scale(1)',
3390 filter: 'none',
3391 boxShadow: '0 4px 16px oklch(0% 0 0 / 0.16), 0 1px 3px oklch(0% 0 0 / 0.1)',
3392 },
3393 ], { duration: 620, easing: EASE });
3394 pendingIntroAnimation.addEventListener('finish', () => { pendingIntroAnimation = null; }, { once: true });
3395 }
3396
3397 function ensureSpinKeyframes() {
3398 if (uiGetById(PREFIX + '-keyframes')) return;
3399 const style = document.createElement('style');
3400 style.id = PREFIX + '-keyframes';
3401 style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }';
3402 uiAppendStyle(style);
3403 }
3404
3405 function pendingApplyLabel(count) {
3406 return count === 1 ? 'Apply copy edit' : 'Apply copy edits';
3407 }
3408
3409 function showManualApplyBusyToast() {
3410 showToast('Apply is still running. Wait for it to finish.', 2800);
3411 }
3412
3413 function manualApplyStateKey() {
3414 return PREFIX + ':manual-apply:' + PORT + ':' + TOKEN + ':' + location.pathname;
3415 }
3416
3417 function readStoredManualApplyState() {
3418 try {
3419 const raw = sessionStorage.getItem(manualApplyStateKey());
3420 if (!raw) return null;
3421 const storedState = JSON.parse(raw);
3422 if (!storedState || storedState.pageUrl !== location.pathname || Date.now() > Number(storedState.expiresAt || 0)) {
3423 sessionStorage.removeItem(manualApplyStateKey());
3424 return null;
3425 }
3426 return storedState;
3427 } catch {
3428 return null;
3429 }
3430 }
3431
3432 function writeManualApplyState(applyState) {
3433 try {
3434 sessionStorage.setItem(manualApplyStateKey(), JSON.stringify({
3435 ...applyState,
3436 pageUrl: location.pathname,
3437 updatedAt: Date.now(),
3438 expiresAt: Date.now() + MANUAL_APPLY_STATE_TTL_MS,
3439 }));
3440 } catch {
3441 // Best-effort only. The in-memory flag still covers non-reload flows.
3442 }
3443 }
3444
3445 function storeManualApplyState(count, patch) {
3446 const currentCount = Number(count) || 0;
3447 const existing = readStoredManualApplyState() || {};
3448 const totalOps = Number(existing.totalOps) || Number(existing.count) || currentCount;
3449 if (totalOps <= 0 && currentCount <= 0) return;
3450 writeManualApplyState({
3451 count: Number(existing.count) || currentCount || totalOps,
3452 totalOps: totalOps || currentCount,
3453 completedOps: Number(existing.completedOps) || 0,
3454 remainingCount: Number.isFinite(Number(existing.remainingCount)) ? Number(existing.remainingCount) : currentCount,
3455 phase: existing.phase || 'applying',
3456 startedAt: Number(existing.startedAt) || Date.now(),
3457 ...(patch || {}),
3458 });
3459 }
3460
3461 function clearStoredManualApplyState() {
3462 try {
3463 sessionStorage.removeItem(manualApplyStateKey());
3464 } catch {
3465 // Ignore storage failures; UI state can still clear in memory.
3466 }
3467 }
3468
3469 function shouldResumeManualApplyLoading(count) {
3470 return Number(count) > 0 && readStoredManualApplyState() !== null;
3471 }
3472
3473 function manualApplyLoadingText(fallbackCount) {
3474 const stored = readStoredManualApplyState();
3475 if (stored?.phase === 'repair-decision') return 'Apply needs attention';
3476 if (stored?.phase === 'repairing') {
3477 const attempt = Number(stored.repairAttempt) || 1;
3478 const max = Number(stored.repairMaxAttempts) || 3;
3479 return 'Fixing apply issue, attempt ' + attempt + '/' + max;
3480 }
3481 if (stored?.phase === 'verifying') return 'Verifying copy edits';
3482 const remaining = Number.isFinite(Number(stored?.remainingCount))
3483 ? Number(stored.remainingCount)
3484 : Number(fallbackCount) || 0;
3485 return remaining > 0
3486 ? 'Applying ' + remaining + ' copy edit' + (remaining === 1 ? '' : 's')
3487 : 'Verifying copy edits';
3488 }
3489
3490 function resetManualApplyProgress(count) {
3491 const total = Number(count) || 0;
3492 if (total <= 0) return;
3493 writeManualApplyState({
3494 count: total,
3495 totalOps: total,
3496 completedOps: 0,
3497 remainingCount: total,
3498 phase: 'applying',
3499 startedAt: Date.now(),
3500 });
3501 }
3502
3503 function updateManualApplyProgressFromChunk(chunk) {
3504 if (!chunk || !pendingApplyInFlight) return;
3505 const stored = readStoredManualApplyState() || {};
3506 const totalOps = Number(chunk.totalOpCount) || Number(stored.totalOps) || Number(stored.count) || parseInt(pendingPillEl?.dataset.count || '0', 10) || 0;
3507 const completedOps = Math.min(totalOps, (Number(stored.completedOps) || 0) + (Number(chunk.opCount) || 0));
3508 const remainingCount = Math.max(0, totalOps - completedOps);
3509 storeManualApplyState(Number(stored.count) || totalOps, {
3510 totalOps,
3511 completedOps,
3512 remainingCount,
3513 phase: remainingCount > 0 ? 'applying' : 'verifying',
3514 });
3515 setPendingApplyLoading(true, remainingCount);
3516 }
3517
3518 function updateManualApplyRepairState(repair, phase) {
3519 const count = parseInt(pendingPillEl?.dataset.count || '0', 10) || Number(readStoredManualApplyState()?.count) || 0;
3520 if (count <= 0) return;
3521 storeManualApplyState(count, {
3522 phase,
3523 repairAttempt: Number(repair?.attempt || repair?.attempts) || 1,
3524 repairMaxAttempts: Number(repair?.maxAttempts) || 3,
3525 });
3526 setPendingApplyLoading(true, count);
3527 }
3528
3529 function refreshLiveControlsForManualApply() {
3530 if (pendingApplyInFlight) {
3531 hideActionPicker();
3532 closeTunePopover();
3533 }
3534 if (barEl && barEl.style.display !== 'none' && state === 'CONFIGURING') {
3535 const input = uiGetById(PREFIX + '-input');
3536 const prompt = input ? input.value : '';
3537 updateBarContent('configure');
3538 const nextInput = uiGetById(PREFIX + '-input');
3539 if (nextInput) nextInput.value = prompt;
3540 }
3541 if (editBadgeEl && editBadgeEl.style.display !== 'none') {
3542 if (pendingApplyInFlight) renderEditBadge('idle-disabled');
3543 else if (state === 'CONFIGURING' && selectedElement && hasTextRows(selectedElement)) renderEditBadge('idle');
3544 }
3545 updateGlobalBarState();
3546 }
3547
3548 function hidePendingApplyDock() {
3549 pendingApplyInFlight = false;
3550 clearStoredManualApplyState();
3551 if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
3552 if (pendingDockEl) pendingDockEl.style.display = 'none';
3553 if (pendingPillEl) {
3554 pendingPillEl.dataset.count = '0';
3555 pendingPillEl.style.display = 'none';
3556 pendingPillEl.disabled = false;
3557 pendingPillEl.setAttribute('aria-busy', 'false');
3558 pendingPillEl.setAttribute('aria-label', 'Apply copy edits to source');
3559 pendingPillEl.style.cursor = 'pointer';
3560 pendingPillEl.style.filter = 'none';
3561 pendingPillEl.style.transform = 'scale(1)';
3562 }
3563 if (pendingPillSpinnerEl) pendingPillSpinnerEl.style.display = 'none';
3564 if (pendingPillLabelEl) pendingPillLabelEl.textContent = pendingApplyLabel(0);
3565 if (pendingPillCountEl) {
3566 pendingPillCountEl.textContent = '0';
3567 pendingPillCountEl.style.display = 'inline-flex';
3568 }
3569 if (pendingTrashBtn) {
3570 pendingTrashBtn.style.display = 'none';
3571 pendingTrashBtn.disabled = false;
3572 pendingTrashBtn.style.cursor = 'pointer';
3573 pendingTrashBtn.style.opacity = '1';
3574 }
3575 if (pendingKeepFixingBtn) pendingKeepFixingBtn.style.display = 'none';
3576 if (pendingRollbackBtn) pendingRollbackBtn.style.display = 'none';
3577 refreshLiveControlsForManualApply();
3578 }
3579
3580 function setPendingApplyLoading(loading, count) {
3581 if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
3582 pendingApplyInFlight = loading === true;
3583 const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
3584 if (pendingApplyInFlight) storeManualApplyState(currentCount);
3585 else clearStoredManualApplyState();
3586 if (pendingPillSpinnerEl) pendingPillSpinnerEl.style.display = pendingApplyInFlight ? 'inline-block' : 'none';
3587 pendingPillLabelEl.textContent = pendingApplyInFlight
3588 ? manualApplyLoadingText(currentCount)
3589 : pendingApplyLabel(currentCount);
3590 pendingPillCountEl.style.display = pendingApplyInFlight ? 'none' : 'inline-flex';
3591 pendingPillEl.disabled = pendingApplyInFlight;
3592 pendingPillEl.setAttribute('aria-busy', pendingApplyInFlight ? 'true' : 'false');
3593 pendingPillEl.style.cursor = pendingApplyInFlight ? 'wait' : 'pointer';
3594 pendingPillEl.style.filter = pendingApplyInFlight ? 'brightness(0.98)' : 'none';
3595 pendingPillEl.style.transform = 'scale(1)';
3596 pendingTrashBtn.disabled = pendingApplyInFlight;
3597 pendingTrashBtn.style.cursor = pendingApplyInFlight ? 'not-allowed' : 'pointer';
3598 pendingTrashBtn.style.opacity = pendingApplyInFlight ? '0.58' : '1';
3599 if (pendingApplyInFlight) {
3600 if (pendingKeepFixingBtn) pendingKeepFixingBtn.style.display = 'none';
3601 if (pendingRollbackBtn) pendingRollbackBtn.style.display = 'none';
3602 pendingTrashBtn.style.display = 'inline-flex';
3603 }
3604 schedulePendingDockPosition();
3605 refreshLiveControlsForManualApply();
3606 }
3607
3608 function updatePendingCounter(currentPageCount) {
3609 if (!pendingDockEl || !pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
3610 const previousCount = parseInt(pendingPillEl.dataset.count || '0', 10);
3611 if (!currentPageCount || currentPageCount <= 0) {
3612 hidePendingApplyDock();
3613 return;
3614 }
3615 pendingPillLabelEl.textContent = pendingApplyLabel(currentPageCount);
3616 pendingPillCountEl.textContent = String(currentPageCount);
3617 pendingPillEl.setAttribute('aria-label', 'Apply ' + currentPageCount + ' copy edit' + (currentPageCount === 1 ? '' : 's') + ' to source');
3618 pendingPillEl.style.display = 'inline-flex';
3619 pendingTrashBtn.style.display = 'inline-flex';
3620 pendingDockEl.style.display = 'inline-flex';
3621 pendingPillEl.dataset.count = String(currentPageCount);
3622 if (pendingApplyInFlight || shouldResumeManualApplyLoading(currentPageCount)) setPendingApplyLoading(true, currentPageCount);
3623 schedulePendingDockPosition();
3624 if (previousCount <= 0) playPendingIntroAnimation();
3625 }
3626
3627 function maybeShowFirstSaveToast() {
3628 if (!firstSaveOfSession) return;
3629 firstSaveOfSession = false;
3630 showToast('Saved. Click "Apply copy edits" to write changes.', 4500);
3631 }
3632
3633 async function fetchPendingCount() {
3634 try {
3635 const res = await fetch(
3636 'http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname),
3637 );
3638 if (!res.ok) return;
3639 const data = await res.json();
3640 updatePendingCounter(data.count || 0);
3641 } catch (err) {
3642 console.warn('[impeccable] failed to fetch pending count:', err);
3643 }
3644 }
3645
3646 async function onPendingPillClick() {
3647 const count = parseInt(pendingPillEl?.dataset.count || '0', 10);
3648 if (count <= 0 || pendingApplyInFlight) return;
3649 const ok = confirm('Apply ' + count + ' copy edit' + (count === 1 ? '' : 's') + ' to source?');
3650 if (!ok) return;
3651 let waitForSseCompletion = false;
3652 resetManualApplyProgress(count);
3653 setPendingApplyLoading(true, count);
3654 try {
3655 const res = await fetch(
3656 'http://localhost:' + PORT + '/manual-edit-commit?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname) + '&async=1',
3657 { method: 'POST', keepalive: true },
3658 );
3659 if (!res.ok) {
3660 const errBody = await res.json().catch(() => ({}));
3661 throw new Error(errBody.error || ('HTTP ' + res.status));
3662 }
3663 const result = await res.json();
3664 if (res.status === 202 || result.status === 'started') {
3665 waitForSseCompletion = true;
3666 return;
3667 }
3668 const remaining = remainingManualEditCount(result);
3669 updatePendingCounter(remaining);
3670 if (result.failed && result.failed.length > 0) {
3671 console.warn('[impeccable] some copy edits failed:', result.failed);
3672 showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed - see console', 5000);
3673 } else {
3674 const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0);
3675 if (n > 0) {
3676 showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500);
3677 } else {
3678 console.warn('[impeccable] apply returned no verified edits:', result);
3679 showToast('No edits applied - see console', 4000);
3680 }
3681 }
3682 } catch (err) {
3683 console.error('[impeccable] commit failed:', err);
3684 showToast('Apply failed - see console', 4000);
3685 } finally {
3686 if (waitForSseCompletion) return;
3687 const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0;
3688 if (remainingCount > 0) setPendingApplyLoading(false);
3689 else hidePendingApplyDock();
3690 }
3691 }
3692
3693 async function onPendingTrashClick() {
3694 const count = parseInt(pendingPillEl?.dataset.count || '0', 10);
3695 if (count <= 0 || pendingApplyInFlight) return;
3696 const ok = confirm('Discard ' + count + ' copy edit' + (count === 1 ? '' : 's') + ' on this page?');
3697 if (!ok) return;
3698 try {
3699 const res = await fetch(
3700 'http://localhost:' + PORT + '/manual-edit-discard?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname),
3701 { method: 'POST' },
3702 );
3703 if (!res.ok) throw new Error('HTTP ' + res.status);
3704 const result = await res.json().catch(() => ({}));
3705 const restoreFailures = restoreDiscardedManualEdits(result.entries || []);
3706 updatePendingCounter(0);
3707 if (restoreFailures > 0) {
3708 showToast('Discarded ' + count + ' copy edit' + (count === 1 ? '' : 's') + ' - refresh to reset ' + restoreFailures, 4000);
3709 } else {
3710 showToast('Discarded ' + count + ' copy edit' + (count === 1 ? '' : 's'), 2500);
3711 }
3712 } catch (err) {
3713 console.error('[impeccable] discard failed:', err);
3714 showToast('Discard failed - see console', 4000);
3715 }
3716 }
3717
3718 function showManualApplyDecision(msg) {
3719 const count = parseInt(pendingPillEl?.dataset.count || '0', 10) || numberOrNull(msg?.remainingCount) || 0;
3720 pendingApplyInFlight = false;
3721 storeManualApplyState(count, {
3722 phase: 'repair-decision',
3723 repairAttempt: numberOrNull(msg?.repair?.attempts) || numberOrNull(msg?.repair?.attempt) || 3,
3724 repairMaxAttempts: numberOrNull(msg?.repair?.maxAttempts) || 3,
3725 });
3726 if (pendingPillSpinnerEl) pendingPillSpinnerEl.style.display = 'none';
3727 if (pendingPillLabelEl) pendingPillLabelEl.textContent = 'Apply needs attention';
3728 if (pendingPillCountEl) pendingPillCountEl.style.display = 'none';
3729 if (pendingPillEl) {
3730 pendingPillEl.disabled = true;
3731 pendingPillEl.setAttribute('aria-busy', 'false');
3732 pendingPillEl.style.cursor = 'default';
3733 pendingPillEl.style.display = 'inline-flex';
3734 }
3735 if (pendingTrashBtn) pendingTrashBtn.style.display = 'none';
3736 if (pendingKeepFixingBtn) pendingKeepFixingBtn.style.display = 'inline-flex';
3737 if (pendingRollbackBtn) pendingRollbackBtn.style.display = 'inline-flex';
3738 if (pendingDockEl) pendingDockEl.style.display = 'inline-flex';
3739 schedulePendingDockPosition();
3740 refreshLiveControlsForManualApply();
3741 }
3742
3743 async function onPendingKeepFixingClick() {
3744 const count = parseInt(pendingPillEl?.dataset.count || '0', 10) || numberOrNull(readStoredManualApplyState()?.count) || 0;
3745 if (count <= 0) return;
3746 updateManualApplyRepairState({ attempt: 1, maxAttempts: 3 }, 'repairing');
3747 try {
3748 const res = await fetch(
3749 'http://localhost:' + PORT + '/manual-edit-commit?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname) + '&async=1&repair=1',
3750 { method: 'POST', keepalive: true },
3751 );
3752 if (!res.ok) throw new Error('HTTP ' + res.status);
3753 if (pendingKeepFixingBtn) pendingKeepFixingBtn.style.display = 'none';
3754 if (pendingRollbackBtn) pendingRollbackBtn.style.display = 'none';
3755 if (pendingTrashBtn) pendingTrashBtn.style.display = 'inline-flex';
3756 } catch (err) {
3757 console.error('[impeccable] repair retry failed:', err);
3758 showToast('Repair retry failed - see console', 4000);
3759 showManualApplyDecision({ remainingCount: count, repair: readStoredManualApplyState() });
3760 }
3761 }
3762
3763 async function onPendingRollbackClick() {
3764 const ok = confirm('Rollback source files to before this Apply and keep the edits staged?');
3765 if (!ok) return;
3766 try {
3767 const res = await fetch(
3768 'http://localhost:' + PORT + '/manual-edit-repair-decision?token=' + encodeURIComponent(TOKEN) + '&pageUrl=' + encodeURIComponent(location.pathname),
3769 {
3770 method: 'POST',
3771 headers: { 'Content-Type': 'application/json' },
3772 body: JSON.stringify({ token: TOKEN, pageUrl: location.pathname, action: 'rollback' }),
3773 },
3774 );
3775 if (!res.ok) throw new Error('HTTP ' + res.status);
3776 const result = await res.json().catch(() => ({}));
3777 clearStoredManualApplyState();
3778 updatePendingCounter(numberOrNull(result.remainingCount) || 0);
3779 showToast('Rolled back source; copy edits are still staged.', 3500);
3780 } catch (err) {
3781 console.error('[impeccable] manual Apply rollback failed:', err);
3782 showToast('Rollback failed - see console', 4000);
3783 }
3784 }
3785
3786 function manualEditEventForCurrentPage(msg) {
3787 return !msg?.pageUrl || msg.pageUrl === location.pathname;
3788 }
3789
3790 function numberOrNull(value) {
3791 const n = Number(value);
3792 return Number.isFinite(n) ? n : null;
3793 }
3794
3795 function remainingManualEditCount(payload) {
3796 const perPageCount = numberOrNull(payload?.perPage?.[location.pathname]);
3797 if (perPageCount !== null) return perPageCount;
3798 const remainingCount = numberOrNull(payload?.remainingCount);
3799 if (remainingCount !== null) return remainingCount;
3800 const totalCount = numberOrNull(payload?.totalCount);
3801 if (totalCount === 0) return 0;
3802 return null;
3803 }
3804
3805 function handleManualEditActivity(msg) {
3806 if (!manualEditEventForCurrentPage(msg)) return;
3807
3808 if (msg.type === 'manual_edit_stashed') {
3809 const pendingCount = numberOrNull(msg.pendingCount);
3810 if (pendingCount !== null) updatePendingCounter(pendingCount);
3811 return;
3812 }
3813
3814 if (msg.type === 'manual_edit_commit_started') {
3815 const pendingCount = numberOrNull(msg.pendingCount);
3816 if (pendingCount !== null && pendingCount > 0) updatePendingCounter(pendingCount);
3817 if (!msg.repairOnly && pendingCount !== null && pendingCount > 0) resetManualApplyProgress(pendingCount);
3818 if (msg.repairOnly) updateManualApplyRepairState({ attempt: 1, maxAttempts: 3 }, 'repairing');
3819 setPendingApplyLoading(true, pendingCount || undefined);
3820 return;
3821 }
3822
3823 if (msg.type === 'manual_edit_apply_reply_received') {
3824 if (msg.chunk) updateManualApplyProgressFromChunk(msg.chunk);
3825 if (msg.repair) updateManualApplyRepairState(msg.repair, 'repairing');
3826 return;
3827 }
3828
3829 if (msg.type === 'manual_edit_apply_dispatched' && msg.repair) {
3830 updateManualApplyRepairState(msg.repair, 'repairing');
3831 return;
3832 }
3833
3834 if (msg.type === 'manual_edit_repair_needs_decision') {
3835 showManualApplyDecision(msg);
3836 return;
3837 }
3838
3839 if (msg.type === 'manual_edit_repair_rollback_done') {
3840 clearStoredManualApplyState();
3841 fetchPendingCount();
3842 return;
3843 }
3844
3845 if (msg.type === 'manual_edit_commit_done') {
3846 if (msg.reason === 'manual_edit_repair_needs_decision' || msg.needsManualDecision === true) {
3847 showManualApplyDecision(msg);
3848 return;
3849 }
3850 // Clear the in-flight flag BEFORE updating the counter. updatePendingCounter
3851 // re-asserts setPendingApplyLoading(true) whenever the flag is still set and
3852 // edits remain (failed entries stay staged), which would otherwise leave the
3853 // picker frozen forever after a partial/failed apply.
3854 const wasApplying = pendingApplyInFlight;
3855 setPendingApplyLoading(false);
3856 const remainingCount = remainingManualEditCount(msg);
3857 updatePendingCounter(remainingCount === null ? 0 : remainingCount);
3858 if (wasApplying) {
3859 const failedCount = numberOrNull(msg.failedCount) || 0;
3860 const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0;
3861 if (failedCount > 0) {
3862 showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed - see console', 5000);
3863 } else if (appliedCount > 0) {
3864 showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500);
3865 }
3866 }
3867 return;
3868 }
3869
3870 if (msg.type === 'manual_edit_commit_failed') {
3871 setPendingApplyLoading(false);
3872 fetchPendingCount();
3873 return;
3874 }
3875
3876 if (msg.type === 'manual_edit_discarded') {
3877 fetchPendingCount();
3878 }
3879 }
3880
3881 function restoreDiscardedManualEdits(entries) {
3882 let failures = 0;
3883 for (const entry of entries || []) {
3884 for (const op of entry.ops || []) {
3885 if (restoreMixedTextNodeManualEdit(op)) continue;
3886 const el = findManualEditRestoreElement(op);
3887 if (!el || typeof op.originalText !== 'string' || !canRestoreManualEditElement(el, op)) {
3888 failures += 1;
3889 continue;
3890 }
3891 el.textContent = op.originalText;
3892 }
3893 }
3894 if (failures > 0) {
3895 console.warn('[impeccable] skipped unsafe copy edit DOM restore for', failures, 'edit(s). Refresh to reset the page DOM.');
3896 }
3897 return failures;
3898 }
3899
3900 function canRestoreManualEditElement(el, op) {
3901 if (!el || typeof op?.originalText !== 'string') return false;
3902 if (el.children && el.children.length > 0) return false;
3903 return normalizeManualContextText(el.textContent) === normalizeManualContextText(op.newText);
3904 }
3905
3906 function mixedTextWrapRestoreHint(el) {
3907 if (!el || !el.dataset || el.dataset.impeccableTextWrap !== 'true' || !el.parentElement) return null;
3908 const siblings = directMixedTextRestoreNodes(el.parentElement);
3909 const textIndex = siblings.indexOf(el);
3910 return {
3911 kind: 'mixedTextNode',
3912 parentRef: documentRefForElement(el.parentElement),
3913 textIndex,
3914 };
3915 }
3916
3917 function restoreMixedTextNodeManualEdit(op) {
3918 const restore = op?.restore;
3919 if (!restore || restore.kind !== 'mixedTextNode' || typeof op?.originalText !== 'string') return false;
3920 const parent = queryManualEditRef(restore.parentRef);
3921 if (!parent) return false;
3922 const textNodes = directMixedTextRestoreNodes(parent).filter((node) => node.nodeType === 3);
3923 const newText = normalizeManualContextText(op.newText);
3924 const byIndex = textNodes[Number(restore.textIndex)];
3925 if (byIndex && normalizeManualContextText(byIndex.nodeValue) === newText) {
3926 byIndex.nodeValue = op.originalText;
3927 return true;
3928 }
3929 const matches = textNodes.filter((node) => normalizeManualContextText(node.nodeValue) === newText);
3930 if (matches.length !== 1) return false;
3931 matches[0].nodeValue = op.originalText;
3932 return true;
3933 }
3934
3935 function directMixedTextRestoreNodes(parent) {
3936 return Array.from(parent?.childNodes || []).filter((node) => {
3937 if (node.nodeType === 3) return /\S/.test(node.nodeValue || '');
3938 return node.nodeType === 1
3939 && node.dataset
3940 && node.dataset.impeccableTextWrap === 'true'
3941 && /\S/.test(node.textContent || '');
3942 });
3943 }
3944
3945 function findManualEditRestoreElement(op) {
3946 for (const ref of [op?.ref, op?.leaf?.ref]) {
3947 const byRef = queryManualEditRef(ref);
3948 if (byRef) return byRef;
3949 }
3950 const tag = op?.tag || op?.leaf?.tagName || '*';
3951 const classes = Array.isArray(op?.classes) ? op.classes : (Array.isArray(op?.leaf?.classes) ? op.leaf.classes : []);
3952 const selector = (tag === '*' ? '' : tag) + classes.map((cls) => '.' + cssIdent(cls)).join('') || '*';
3953 let matches = [];
3954 try {
3955 matches = Array.from(document.querySelectorAll(selector));
3956 } catch {
3957 matches = [];
3958 }
3959 const newText = normalizeManualContextText(op?.newText);
3960 const filtered = matches.filter((el) => normalizeManualContextText(el.textContent) === newText);
3961 return filtered.length === 1 ? filtered[0] : null;
3962 }
3963
3964 function queryManualEditRef(ref) {
3965 if (!ref || typeof ref !== 'string') return null;
3966 const parts = ref.split('>').map((part) => part.trim()).filter(Boolean);
3967 let current = null;
3968 for (let index = 0; index < parts.length; index += 1) {
3969 const segment = parseManualEditRefSegment(parts[index]);
3970 if (!segment) return null;
3971 if (index === 0 && segment.tag === 'body') {
3972 current = document.body;
3973 if (!elementMatchesManualRefSegment(current, segment)) return null;
3974 continue;
3975 }
3976 const scope = current || document.body;
3977 const children = Array.from(scope.children || []);
3978 current = children.find((child) => elementMatchesManualRefSegment(child, segment)) || null;
3979 if (!current) return null;
3980 }
3981 return current;
3982 }
3983
3984 function parseManualEditRefSegment(segment) {
3985 const nthMatch = String(segment || '').match(/:nth-of-type\((\d+)\)$/);
3986 const nth = nthMatch ? Number(nthMatch[1]) : null;
3987 const base = nthMatch ? segment.slice(0, nthMatch.index) : segment;
3988 const tagMatch = base.match(/^[^#.:\s]+/);
3989 const tag = tagMatch ? tagMatch[0].toLowerCase() : null;
3990 if (!tag) return null;
3991 const idMatch = base.match(/#([^#.]+)/);
3992 const classes = base
3993 .slice(tag.length)
3994 .replace(/#[^#.]+/, '')
3995 .split('.')
3996 .filter(Boolean);
3997 return { tag, id: idMatch ? idMatch[1] : null, classes, nth };
3998 }
3999
4000 function elementMatchesManualRefSegment(el, segment) {
4001 if (!el || !segment) return false;
4002 if (el.tagName.toLowerCase() !== segment.tag) return false;
4003 if (segment.id && el.id !== segment.id) return false;
4004 for (const cls of segment.classes) {
4005 if (!el.classList || !el.classList.contains(cls)) return false;
4006 }
4007 if (segment.nth && indexAmongSameTag(el) !== segment.nth) return false;
4008 return true;
4009 }
4010
4011 function cssIdent(value) {
4012 if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value));
4013 return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
4014 }
4015
4016 //
4017 // Edit content badge - floating button at element top-right to enter EDITING mode
4018 //
4019
4020 function usesShadowChromeRoot() {
4021 const root = liveUiRoot();
4022 return root && root !== document.body && root.host && root.host.id === PREFIX + '-root';
4023 }
4024
4025 function setImportantStyle(el, name, value) {
4026 el.style.setProperty(name, value, 'important');
4027 }
4028
4029 function initEditBadgeHitProxies() {
4030 if (!usesShadowChromeRoot() || editBadgeProxyRoot) return;
4031 editBadgeProxyRoot = document.createElement('div');
4032 editBadgeProxyRoot.id = PREFIX + '-edit-badge-hit-proxies';
4033 editBadgeProxyRoot.setAttribute('aria-hidden', 'true');
4034 const styles = {
4035 all: 'initial',
4036 position: 'fixed',
4037 inset: '0',
4038 width: '100vw',
4039 height: '100vh',
4040 zIndex: String(Z.toast + 1),
4041 pointerEvents: 'none',
4042 background: 'transparent',
4043 overflow: 'visible',
4044 };
4045 for (const [name, value] of Object.entries(styles)) {
4046 setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value);
4047 }
4048 document.body.appendChild(editBadgeProxyRoot);
4049 }
4050
4051 function styleEditBadgeProxy(proxy, target) {
4052 const rect = target.getBoundingClientRect();
4053 const cursor = getComputedStyle(target).cursor || 'pointer';
4054 const styles = {
4055 all: 'initial',
4056 position: 'fixed',
4057 left: rect.left + 'px',
4058 top: rect.top + 'px',
4059 width: rect.width + 'px',
4060 height: rect.height + 'px',
4061 margin: '0',
4062 padding: '0',
4063 border: '0',
4064 borderRadius: '0',
4065 background: 'transparent',
4066 color: 'transparent',
4067 opacity: '0.001',
4068 pointerEvents: 'auto',
4069 cursor,
4070 zIndex: String(Z.toast + 2),
4071 };
4072 for (const [name, value] of Object.entries(styles)) {
4073 setImportantStyle(proxy, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value);
4074 }
4075 }
4076
4077 function proxyMouseEvent(type, source, target) {
4078 let event;
4079 try {
4080 event = new MouseEvent(type, {
4081 bubbles: type !== 'mouseenter' && type !== 'mouseleave',
4082 cancelable: true,
4083 composed: true,
4084 clientX: source.clientX,
4085 clientY: source.clientY,
4086 screenX: source.screenX,
4087 screenY: source.screenY,
4088 button: source.button || 0,
4089 buttons: source.buttons || 0,
4090 ctrlKey: source.ctrlKey,
4091 metaKey: source.metaKey,
4092 shiftKey: source.shiftKey,
4093 altKey: source.altKey,
4094 });
4095 target.dispatchEvent(event);
4096 } catch {}
4097 }
4098
4099 function bindEditBadgeProxy(proxy, target) {
4100 const stop = (event) => {
4101 event.preventDefault();
4102 event.stopPropagation();
4103 };
4104 proxy.addEventListener('mouseenter', (event) => {
4105 stop(event);
4106 proxyMouseEvent('mouseenter', event, target);
4107 proxyMouseEvent('mouseover', event, target);
4108 });
4109 proxy.addEventListener('mouseleave', (event) => {
4110 stop(event);
4111 proxyMouseEvent('mouseleave', event, target);
4112 proxyMouseEvent('mouseout', event, target);
4113 });
4114 proxy.addEventListener('mousedown', (event) => {
4115 stop(event);
4116 target.focus?.({ preventScroll: true });
4117 proxyMouseEvent('mousedown', event, target);
4118 });
4119 proxy.addEventListener('mouseup', (event) => {
4120 stop(event);
4121 proxyMouseEvent('mouseup', event, target);
4122 });
4123 proxy.addEventListener('click', (event) => {
4124 stop(event);
4125 target.click();
4126 syncEditBadgeHitProxies();
4127 });
4128 }
4129
4130 function editBadgeProxyTargets() {
4131 if (!usesShadowChromeRoot() || !editBadgeEl || editBadgeEl.style.display === 'none') return [];
4132 return [...editBadgeEl.querySelectorAll('button')].filter((target) => {
4133 if (target.disabled) return false;
4134 const rect = target.getBoundingClientRect();
4135 if (rect.width < 1 || rect.height < 1) return false;
4136 const style = getComputedStyle(target);
4137 return style.display !== 'none' && style.visibility !== 'hidden';
4138 });
4139 }
4140
4141 function syncEditBadgeHitProxies() {
4142 if (!usesShadowChromeRoot()) {
4143 if (editBadgeProxyRoot) editBadgeProxyRoot.remove();
4144 editBadgeProxyRoot = null;
4145 editBadgeProxyByTarget = new Map();
4146 return;
4147 }
4148 initEditBadgeHitProxies();
4149 if (!editBadgeProxyRoot) return;
4150 const targets = editBadgeProxyTargets();
4151 const active = new Set(targets);
4152 for (const [target, proxy] of editBadgeProxyByTarget) {
4153 if (!active.has(target) || !target.isConnected) {
4154 proxy.remove();
4155 editBadgeProxyByTarget.delete(target);
4156 }
4157 }
4158 for (const target of targets) {
4159 let proxy = editBadgeProxyByTarget.get(target);
4160 if (!proxy) {
4161 proxy = document.createElement('button');
4162 proxy.type = 'button';
4163 proxy.tabIndex = -1;
4164 proxy.dataset.impeccableEditBadgeProxy = 'true';
4165 proxy.setAttribute('aria-hidden', 'true');
4166 bindEditBadgeProxy(proxy, target);
4167 editBadgeProxyRoot.appendChild(proxy);
4168 editBadgeProxyByTarget.set(target, proxy);
4169 }
4170 proxy.title = target.title || target.textContent || 'Edit copy';
4171 styleEditBadgeProxy(proxy, target);
4172 }
4173 }
4174
4175 function initEditBadge() {
4176 editBadgeEl = document.createElement('div');
4177 editBadgeEl.id = PREFIX + '-edit-badge';
4178 Object.assign(editBadgeEl.style, {
4179 position: 'fixed',
4180 zIndex: String(Z.highlight + 1),
4181 cursor: 'default',
4182 display: 'none',
4183 userSelect: 'none',
4184 });
4185 uiAppend(editBadgeEl);
4186 initEditBadgeHitProxies();
4187
4188 // Remove focus rings on edit badge buttons + contenteditable elements
4189 if (!uiGetById(PREFIX + '-edit-badge-focus-style')) {
4190 const s = document.createElement('style');
4191 s.id = PREFIX + '-edit-badge-focus-style';
4192 s.textContent =
4193 '#' + PREFIX + '-edit-badge button { outline: none !important; box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important; }' +
4194 '#' + PREFIX + '-edit-badge button:focus { outline: none !important; }' +
4195 '#' + PREFIX + '-edit-badge button:focus-visible { outline: none !important; }' +
4196 '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' +
4197 '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' +
4198 '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }';
4199 uiAppendStyle(s);
4200 }
4201 }
4202
4203 function positionEditBadge() {
4204 if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') {
4205 syncEditBadgeHitProxies();
4206 return;
4207 }
4208 const r = selectedElement.getBoundingClientRect();
4209 const bw = editBadgeEl.offsetWidth;
4210 editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px';
4211 editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px';
4212 syncEditBadgeHitProxies();
4213 }
4214
4215 function renderEditBadge(mode) {
4216 if (mode === 'hidden' || !editBadgeEl) {
4217 if (editBadgeEl) editBadgeEl.style.display = 'none';
4218 syncEditBadgeHitProxies();
4219 return;
4220 }
4221 editBadgeEl.style.display = 'flex';
4222 editBadgeEl.style.alignItems = 'center';
4223 editBadgeEl.style.cursor = 'default';
4224 const P = BP || barPaletteForTheme(detectPageTheme());
4225 const ACCENT = P.accent;
4226 const PRIMARY_TEXT = C.ink;
4227 const SURFACE = P.chatSurface;
4228 const MUTED = P.textDim;
4229 const HAIRLINE = P.hairline;
4230 const calloutStyle = (color, borderColor) => ({
4231 fontFamily: FONT,
4232 fontSize: '10px',
4233 fontWeight: '600',
4234 lineHeight: '16px',
4235 letterSpacing: '0.06em',
4236 color: color,
4237 background: SURFACE,
4238 padding: '2px 8px',
4239 border: '1px solid ' + (borderColor || color),
4240 borderRadius: '6px',
4241 boxSizing: 'border-box',
4242 minHeight: '22px',
4243 margin: '0',
4244 appearance: 'none',
4245 whiteSpace: 'nowrap',
4246 boxShadow: '0 4px 16px oklch(0% 0 0 / 0.16), 0 1px 3px oklch(0% 0 0 / 0.08)',
4247 cursor: 'pointer',
4248 transition: 'background 0.18s ease, color 0.18s ease, border-color 0.18s ease, filter 0.18s ease',
4249 });
4250 if (mode === 'idle' || mode === 'idle-disabled') {
4251 const disabled = mode === 'idle-disabled';
4252 editBadgeEl.innerHTML = '';
4253 const btn = document.createElement('button');
4254 btn.textContent = 'Edit copy';
4255 Object.assign(btn.style, calloutStyle(disabled ? MUTED : ACCENT, disabled ? HAIRLINE : ACCENT));
4256 if (disabled) {
4257 btn.style.cursor = 'not-allowed';
4258 btn.style.opacity = '0.55';
4259 btn.disabled = true;
4260 btn.title = 'Edit copy is disabled while the current copy edit is applying';
4261 } else {
4262 btn.addEventListener('mouseenter', () => { btn.style.background = ACCENT; btn.style.color = PRIMARY_TEXT; });
4263 btn.addEventListener('mouseleave', () => { btn.style.background = SURFACE; btn.style.color = ACCENT; });
4264 btn.onclick = enterEditingMode;
4265 }
4266 editBadgeEl.appendChild(btn);
4267 } else {
4268 // 'editing' - show Cancel + Save separated
4269 editBadgeEl.innerHTML = '';
4270 editBadgeEl.style.gap = '8px';
4271 const cancel = document.createElement('button');
4272 cancel.textContent = 'Cancel';
4273 Object.assign(cancel.style, calloutStyle(MUTED, HAIRLINE));
4274 cancel.addEventListener('mouseenter', () => { cancel.style.color = P.text; });
4275 cancel.addEventListener('mouseleave', () => { cancel.style.color = P.textDim; });
4276 cancel.onclick = cancelEditing;
4277 const save = document.createElement('button');
4278 save.textContent = 'Save';
4279 Object.assign(save.style, calloutStyle(ACCENT));
4280 save.addEventListener('mouseenter', () => { save.style.background = ACCENT; save.style.color = PRIMARY_TEXT; });
4281 save.addEventListener('mouseleave', () => { save.style.background = SURFACE; save.style.color = ACCENT; });
4282 save.onclick = applyEditing;
4283 editBadgeEl.append(cancel, save);
4284 }
4285 positionEditBadge();
4286 }
4287
4288 // Decide which way the popover opens: away from the picked element. If the
4289 // bar landed below the element, popover slides DOWN from the bar's bottom.
4290 // If the bar landed above, popover slides UP from the bar's top.
4291 function popoverDirection() {
4292 if (!barEl || !selectedElement) return 'below';
4293 const br = barEl.getBoundingClientRect();
4294 const er = selectedElement.getBoundingClientRect();
4295 return br.top >= er.bottom - 4 ? 'below' : 'above';
4296 }
4297
4298 // The popover overlaps the bar by OVERLAP px on the bar-facing side. With
4299 // popover z-index below bar, that overlap sits behind bar (invisible) and
4300 // reinforces the "tucked behind" feel. Padding compensates so the real
4301 // content starts flush with bar's outer edge.
4302 const TUNE_OVERLAP = 6;
4303
4304 // Closed clip-path depends on direction: for 'below' clip from the far
4305 // (bottom) edge so the reveal grows downward from the bar; for 'above'
4306 // clip from the top edge so the reveal grows upward from the bar.
4307 function closedClipPath(direction) {
4308 return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
4309 }
4310
4311 function setClipPath(value, withTransition) {
4312 const saved = paramsPanelEl.style.transition;
4313 if (!withTransition) paramsPanelEl.style.transition = 'none';
4314 paramsPanelEl.style.clipPath = value;
4315 if (!withTransition) {
4316 void paramsPanelEl.offsetHeight;
4317 paramsPanelEl.style.transition = saved;
4318 }
4319 }
4320
4321 function positionParamsPanel() {
4322 if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
4323 const br = barEl.getBoundingClientRect();
4324 const direction = popoverDirection();
4325 const prevDirection = paramsPanelEl.dataset.tuneDirection;
4326
4327 // top/left/width are NOT in the transition list, so they snap instantly.
4328 paramsPanelEl.style.left = br.left + 'px';
4329 paramsPanelEl.style.width = br.width + 'px';
4330
4331 if (direction === 'below') {
4332 paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
4333 paramsPanelEl.style.borderRadius = '0 0 10px 10px';
4334 paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
4335 paramsPanelEl.style.paddingBottom = '14px';
4336 } else {
4337 const ih = paramsPanelEl.offsetHeight || 80;
4338 paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
4339 paramsPanelEl.style.borderRadius = '10px 10px 0 0';
4340 paramsPanelEl.style.paddingTop = '14px';
4341 paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
4342 }
4343 paramsPanelEl.dataset.tuneDirection = direction;
4344
4345 // If currently closed and direction flipped (or first-time setup),
4346 // snap the clip-path to the new direction's closed pose without
4347 // transitioning (so the clip doesn't slide across the element).
4348 if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
4349 setClipPath(closedClipPath(direction), false);
4350 }
4351 }
4352
4353 function showParamsPanel() {
4354 if (!paramsPanelEl) return;
4355 positionParamsPanel();
4356 paramsPanelEl.style.pointerEvents = 'auto';
4357 // rAF so the positioning paint commits before the transition fires.
4358 requestAnimationFrame(() => {
4359 setClipPath('inset(0 0 0 0)', true);
4360 });
4361 }
4362
4363 function hideParamsPanel() {
4364 if (!paramsPanelEl) return;
4365 paramsPanelEl.style.pointerEvents = 'none';
4366 const direction = paramsPanelEl.dataset.tuneDirection || 'below';
4367 setClipPath(closedClipPath(direction), true);
4368 }
4369
4370 // Build/rebuild the panel's contents for the current variant AND apply
4371 // its defaults to the variant wrapper (so scoped CSS responds even before
4372 // the user opens the popover). Visibility is governed by tuneOpen.
4373 function refreshParamsPanel() {
4374 if (state !== 'CYCLING') {
4375 paramsCurrentValues = {};
4376 tuneOpen = false;
4377 hideParamsPanel();
4378 return;
4379 }
4380 const variantEl = getVisibleVariantEl();
4381 const params = parseVariantParams(variantEl);
4382 if (!variantEl || params.length === 0) {
4383 paramsCurrentValues = {};
4384 tuneOpen = false;
4385 hideParamsPanel();
4386 return;
4387 }
4388 applyParamDefaults(variantEl, params);
4389 buildParamsPanel(variantEl, params);
4390 if (tuneOpen) {
4391 // If already visible (variant cycled while open), refresh in place
4392 // instead of re-running the clip-path animation.
4393 const alreadyVisible = paramsPanelEl.style.display === 'block'
4394 && paramsPanelEl.style.opacity === '1';
4395 if (alreadyVisible) positionParamsPanel();
4396 else showParamsPanel();
4397 } else {
4398 hideParamsPanel();
4399 }
4400 }
4401
4402 function toggleTunePopover() {
4403 if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
4404 if (tuneOpen) { closeTunePopover(); return; }
4405 openTunePopover();
4406 }
4407
4408 function openTunePopover() {
4409 if (state !== 'CYCLING') return;
4410 const variantEl = getVisibleVariantEl();
4411 const params = parseVariantParams(variantEl);
4412 if (!variantEl || params.length === 0) return;
4413 // Build fresh to ensure the current variant's controls are shown.
4414 applyParamDefaults(variantEl, params);
4415 buildParamsPanel(variantEl, params);
4416 tuneOpen = true;
4417 showParamsPanel();
4418 // Kill the bar's shadow on the popover-facing side so the dark popover
4419 // doesn't pick up a bright glow line.
4420 if (barEl) {
4421 const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
4422 barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
4423 }
4424 // Re-render the bar so the Tune chip picks up the active styling.
4425 showOrUpdateCyclingBar();
4426 }
4427
4428 function closeTunePopover() {
4429 tuneOpen = false;
4430 hideParamsPanel();
4431 if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
4432 if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
4433 showOrUpdateCyclingBar();
4434 }
4435 }
4436
4437 //
4438 // Variant cycling in DOM
4439 //
4440
4441 function isVariantShown(el) {
4442 if (!el) return false;
4443 if (el.hidden) return false;
4444 if (el.style?.display === 'none') return false;
4445 return true;
4446 }
4447
4448 function setVariantShown(el, shown) {
4449 if (!el) return;
4450 if (shown) {
4451 el.removeAttribute('hidden');
4452 el.style.display = '';
4453 } else {
4454 el.setAttribute('hidden', '');
4455 el.style.display = 'none';
4456 }
4457 }
4458
4459 function scheduleCyclingBarSync(sessionId, variantNum) {
4460 requestAnimationFrame(() => {
4461 if (state !== 'CYCLING') return;
4462 if (currentSessionId !== sessionId) return;
4463 if (visibleVariant !== variantNum) return;
4464 showOrUpdateCyclingBar();
4465 syncCyclingControls();
4466 positionBar();
4467 });
4468 }
4469
4470 function syncCyclingControls() {
4471 const shown = svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0
4472 ? svelteComponentSession.mountedVariant
4473 : visibleVariant;
4474 const counter = uiGetById(PREFIX + '-variant-counter');
4475 if (counter && arrivedVariants > 0) counter.textContent = shown + '/' + arrivedVariants;
4476 const prev = uiGetById(PREFIX + '-variant-prev');
4477 const next = uiGetById(PREFIX + '-variant-next');
4478 if (prev) prev.style.opacity = shown <= 1 ? '0.3' : '1';
4479 if (next) next.style.opacity = shown >= arrivedVariants ? '0.3' : '1';
4480 if (currentSessionId && state === 'CYCLING') saveSession();
4481 }
4482
4483 async function showVariantInDOM(sessionId, num) {
4484 if (svelteComponentSession?.sessionId === sessionId) {
4485 visibleVariant = num;
4486 const mounted = await mountSvelteComponentVariant(num);
4487 if (!mounted) return false;
4488 updateSelectedElement();
4489 refreshParamsPanel();
4490 scheduleCyclingBarSync(sessionId, num);
4491 return true;
4492 }
4493 const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
4494 if (!wrapper) return false;
4495 for (const child of wrapper.children) {
4496 const v = child.dataset ? child.dataset.impeccableVariant : null;
4497 if (!v) continue;
4498 setVariantShown(child, v === String(num));
4499 }
4500 // Unconditional refresh - covers first-reveal (no-op if state isn't
4501 // CYCLING yet, the subsequent CYCLING transition triggers its own
4502 // refresh) and every cycle step.
4503 refreshParamsPanel();
4504 return true;
4505 }
4506
4507 function isSvelteComponentManifestPath(filePath) {
4508 return String(filePath || '').endsWith('manifest.json');
4509 }
4510
4511 function parseOriginalMarkupElement(originalMarkup) {
4512 const parser = new DOMParser();
4513 const doc = parser.parseFromString('<div id="impeccable-anchor">' + originalMarkup + '</div>', 'text/html');
4514 return doc.getElementById('impeccable-anchor')?.firstElementChild || null;
4515 }
4516
4517 function findLiveElementForOriginalMarkup(originalMarkup) {
4518 const origContent = parseOriginalMarkupElement(originalMarkup);
4519 if (!origContent) return null;
4520
4521 const tag = origContent.tagName.toLowerCase();
4522 const cls = origContent.className;
4523 let liveEl = null;
4524 if (origContent.id) {
4525 liveEl = document.getElementById(origContent.id);
4526 } else if (cls) {
4527 const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]);
4528 for (const c of candidates) {
4529 if (c.className === cls && !own(c)) { liveEl = c; break; }
4530 }
4531 if (!liveEl) {
4532 const expectedClasses = String(cls).split(/\s+/).filter(Boolean);
4533 for (const c of candidates) {
4534 if (own(c)) continue;
4535 if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; }
4536 }
4537 }
4538 }
4539 return liveEl;
4540 }
4541
4542 function isSvelteInsertManifest(manifest) {
4543 return manifest?.previewMode === 'svelte-component' && manifest?.mode === 'insert';
4544 }
4545
4546 function findLiveElementForSvelteManifest(manifest) {
4547 if (isSvelteInsertManifest(manifest)) {
4548 const anchor = findInsertAnchorInDom();
4549 if (anchor?.parentElement) return anchor;
4550 }
4551 return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || '');
4552 }
4553
4554 function loadSvelteRuntime(runtimeModule) {
4555 const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js';
4556 const url = new URL(modulePath, location.origin).href;
4557 if (!svelteRuntimePromise) {
4558 svelteRuntimePromise = import(/* @vite-ignore */ url);
4559 }
4560 return svelteRuntimePromise;
4561 }
4562
4563 // Svelte component variants declare their params in a sidecar params.json under
4564 // componentDir (keyed by variant number), because a `data-impeccable-params`
4565 // attribute with JSON braces can't survive the Svelte compiler. Returns a map of
4566 // { "1": [...params], "2": [...] }; an empty object when the agent declared none.
4567 async function loadSvelteComponentParams(manifest) {
4568 const dir = String(manifest?.componentDir || '').replace(/^\/+/, '');
4569 if (!dir) return {};
4570 const paramsPath = dir + '/params.json';
4571 const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(paramsPath);
4572 try {
4573 const res = await fetch(url);
4574 if (!res.ok) return {};
4575 const parsed = JSON.parse(await res.text());
4576 if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
4577 const out = {};
4578 for (const [key, value] of Object.entries(parsed)) {
4579 if (Array.isArray(value)) out[String(key)] = value;
4580 }
4581 return out;
4582 } catch {
4583 return {};
4584 }
4585 }
4586
4587 function buildSveltePropValuesFromLiveElement(liveEl, manifest) {
4588 const contract = manifest?.propContract || [];
4589 const values = {};
4590 if (!liveEl || contract.length === 0) return values;
4591 const sourceOriginal = parseOriginalMarkupElement(manifest.originalMarkup || '');
4592 if (!sourceOriginal) return values;
4593 const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl);
4594 for (const entry of contract) {
4595 const token = '{' + entry.expr + '}';
4596 values[entry.prop] = map.get(token) || '';
4597 }
4598 return values;
4599 }
4600
4601 async function mountSvelteComponentVariant(variantNum) {
4602 if (!svelteComponentSession || !variantNum) return false;
4603 const { manifest, mountTargetEl, sessionId } = svelteComponentSession;
4604 try {
4605 const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement;
4606 svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null;
4607 const runtime = await loadSvelteRuntime(manifest.runtimeModule);
4608 const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte';
4609 const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now();
4610 const mod = await import(/* @vite-ignore */ moduleUrl);
4611 const Component = mod.default;
4612 if (svelteComponentSession.mountedInstance && runtime.unmount) {
4613 await runtime.unmount(svelteComponentSession.mountedInstance);
4614 svelteComponentSession.mountedInstance = null;
4615 }
4616 svelteComponentSession.mountedInstance = runtime.mount(Component, {
4617 target: mountTargetEl,
4618 props: { ...svelteComponentSession.propValues },
4619 intro: false,
4620 });
4621 svelteComponentSession.mountedVariant = variantNum;
4622 svelteComponentSession.runtime = runtime;
4623 if (state === 'CYCLING') syncCyclingControls();
4624 const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession);
4625 if (nextAnchor) {
4626 if (!isSvelteInsertManifest(manifest)) {
4627 applyOriginalAttrsToSvelteAnchor(nextAnchor, manifest.originalMarkup || '');
4628 }
4629 svelteComponentSession.swapAnchor = null;
4630 selectedElement = nextAnchor;
4631 } else {
4632 requestAnimationFrame(() => {
4633 if (svelteComponentSession?.sessionId !== sessionId) return;
4634 const settledAnchor = getMountedSvelteComponentAnchor(svelteComponentSession);
4635 if (!settledAnchor) return;
4636 if (!isSvelteInsertManifest(manifest)) {
4637 applyOriginalAttrsToSvelteAnchor(settledAnchor, manifest.originalMarkup || '');
4638 }
4639 svelteComponentSession.swapAnchor = null;
4640 selectedElement = settledAnchor;
4641 });
4642 }
4643 return true;
4644 } catch (err) {
4645 if (svelteComponentSession?.sessionId === sessionId) {
4646 svelteComponentSession.swapAnchor = null;
4647 }
4648 console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err);
4649 return false;
4650 }
4651 }
4652
4653 function teardownSvelteComponentSession(restoreOriginal) {
4654 if (!svelteComponentSession) return;
4655 const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession;
4656 if (mountedInstance && runtime?.unmount) {
4657 try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ }
4658 }
4659 if (restoreOriginal && detachedOriginal && wrapperEl?.parentElement) {
4660 wrapperEl.parentElement.replaceChild(detachedOriginal, wrapperEl);
4661 } else if (wrapperEl?.parentElement) {
4662 wrapperEl.remove();
4663 }
4664 svelteComponentSession = null;
4665 svelteRuntimePromise = null;
4666 }
4667
4668 function applyOriginalAttrsToSvelteAnchor(el, originalMarkup) {
4669 if (!el || !originalMarkup) return;
4670 const original = parseOriginalMarkupElement(originalMarkup);
4671 if (!original || original.tagName !== el.tagName) return;
4672 for (const attr of original.attributes) {
4673 if (attr.name === 'class') {
4674 for (const className of attr.value.split(/\s+/).filter(Boolean)) {
4675 el.classList.add(className);
4676 }
4677 } else if (!el.hasAttribute(attr.name)) {
4678 el.setAttribute(attr.name, attr.value);
4679 }
4680 }
4681 }
4682
4683 function commitAcceptedSvelteComponentToDom(sessionId) {
4684 if (!svelteComponentSession || svelteComponentSession.sessionId !== sessionId) return false;
4685 const { wrapperEl, runtime, mountedInstance, manifest } = svelteComponentSession;
4686 const anchor = getMountedSvelteComponentAnchor(svelteComponentSession);
4687 if (!anchor || !wrapperEl?.parentElement) return false;
4688 const committed = anchor.cloneNode(true);
4689 if (!isSvelteInsertManifest(manifest)) {
4690 applyOriginalAttrsToSvelteAnchor(committed, manifest.originalMarkup || '');
4691 }
4692 if (mountedInstance && runtime?.unmount) {
4693 try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ }
4694 }
4695 wrapperEl.parentElement.replaceChild(committed, wrapperEl);
4696 svelteComponentSession = null;
4697 svelteRuntimePromise = null;
4698 selectedElement = committed;
4699 return true;
4700 }
4701
4702 async function injectSvelteComponentsFromManifest(manifestPath, sessionId) {
4703 const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(manifestPath);
4704 try {
4705 const res = await fetch(url);
4706 if (!res.ok) throw new Error(String(res.status));
4707 const manifest = JSON.parse(await res.text());
4708 if (manifest.id !== sessionId) return;
4709
4710 const paramsByVariant = await loadSvelteComponentParams(manifest);
4711 currentSessionId = sessionId;
4712 expectedVariants = Number(manifest.count) || expectedVariants || 1;
4713 rememberSessionFileMeta({
4714 sourceFile: manifest.sourceFile,
4715 previewFile: manifestPath,
4716 previewMode: 'svelte-component',
4717 });
4718 if (state !== 'CYCLING') state = 'GENERATING';
4719
4720 const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
4721 if (existingWrapper && svelteComponentSession?.sessionId === sessionId) {
4722 recoveryWaitingForAnchor = false;
4723 svelteComponentSession.paramsByVariant = paramsByVariant;
4724 arrivedVariants = Number(manifest.count) || expectedVariants || 1;
4725 expectedVariants = arrivedVariants;
4726 visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1;
4727 await mountSvelteComponentVariant(visibleVariant || 1);
4728 state = 'CYCLING';
4729 showOrUpdateCyclingBar();
4730 saveSession();
4731 return;
4732 }
4733
4734 const liveEl = findLiveElementForSvelteManifest(manifest);
4735 if (!liveEl?.parentElement) {
4736 console.warn('[impeccable] Could not find original element in live DOM.');
4737 arrivedVariants = Number(manifest.count) || expectedVariants || 1;
4738 expectedVariants = arrivedVariants;
4739 const saved = loadSession();
4740 const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
4741 visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants
4742 ? visibleVariant
4743 : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
4744 selectedElement = document.body;
4745 state = 'GENERATING';
4746 recoveryWaitingForAnchor = true;
4747 showBar('generating');
4748 startScrollTracking();
4749 saveSession();
4750 queueCheckpoint('svelte_component_anchor_missing');
4751 waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest });
4752 showToast('Variants ready. Reveal the selected element to resume.', 15000);
4753 return;
4754 }
4755
4756 const wrapper = document.createElement('div');
4757 wrapper.dataset.impeccableVariants = sessionId;
4758 wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1);
4759 wrapper.dataset.impeccablePreview = 'svelte-component';
4760 wrapper.style.display = 'contents';
4761
4762 const mountTarget = document.createElement('div');
4763 mountTarget.dataset.impeccableComponentMount = sessionId;
4764 mountTarget.style.display = 'contents';
4765 wrapper.appendChild(mountTarget);
4766
4767 const insertMode = isSvelteInsertManifest(manifest);
4768 const detachedOriginal = insertMode ? null : liveEl;
4769 if (insertMode) {
4770 removeInsertPlaceholderDom();
4771 if (manifest.position === 'before') liveEl.parentElement.insertBefore(wrapper, liveEl);
4772 else liveEl.parentElement.insertBefore(wrapper, liveEl.nextSibling);
4773 } else {
4774 liveEl.parentElement.replaceChild(wrapper, liveEl);
4775 }
4776
4777 svelteComponentSession = {
4778 sessionId,
4779 manifest,
4780 insertMode,
4781 wrapperEl: wrapper,
4782 mountTargetEl: mountTarget,
4783 detachedOriginal,
4784 mountedInstance: null,
4785 mountedVariant: 0,
4786 runtime: null,
4787 propValues: buildSveltePropValuesFromLiveElement(detachedOriginal, manifest),
4788 paramsByVariant,
4789 };
4790 if (pendingSvelteComponentRetryObserver) {
4791 pendingSvelteComponentRetryObserver.disconnect();
4792 pendingSvelteComponentRetryObserver = null;
4793 }
4794 recoveryWaitingForAnchor = false;
4795
4796 const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0;
4797 arrivedVariants = Number(manifest.count) || expectedVariants || 1;
4798 expectedVariants = arrivedVariants;
4799 const saved = loadSession();
4800 const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
4801 visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants
4802 ? previousVisibleVariant
4803 : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
4804
4805 const mounted = await mountSvelteComponentVariant(visibleVariant);
4806 if (!mounted) {
4807 // The compiled component threw (e.g. a Svelte compile error in the
4808 // variant file). Don't strand the bar in an empty CYCLING state; restore
4809 // the original element and reset to PICKING so the user can retry.
4810 abortSvelteComponentInjection(sessionId, 'A variant failed to compile. Fix the component and re-run.');
4811 return;
4812 }
4813
4814 selectedElement = mountTarget.firstElementChild || mountTarget;
4815 state = 'CYCLING';
4816 recoveryWaitingForAnchor = false;
4817 hideShaderOverlay();
4818 showOrUpdateCyclingBar();
4819 disableInlineEdit();
4820 refreshParamsPanel();
4821 positionBar();
4822 saveSession();
4823 console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.');
4824 } catch (err) {
4825 console.error('[impeccable] Failed to mount Svelte component variants:', err);
4826 abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.');
4827 }
4828 }
4829
4830 function waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }) {
4831 if (pendingSvelteComponentRetryObserver) pendingSvelteComponentRetryObserver.disconnect();
4832 pendingSvelteComponentRetryObserver = new MutationObserver(() => {
4833 if (svelteComponentSession?.sessionId === sessionId) {
4834 pendingSvelteComponentRetryObserver.disconnect();
4835 pendingSvelteComponentRetryObserver = null;
4836 return;
4837 }
4838 const liveEl = findLiveElementForSvelteManifest(manifest);
4839 if (!liveEl?.parentElement) return;
4840 pendingSvelteComponentRetryObserver.disconnect();
4841 pendingSvelteComponentRetryObserver = null;
4842 injectSvelteComponentsFromManifest(manifestPath, sessionId);
4843 });
4844 pendingSvelteComponentRetryObserver.observe(document.body, { childList: true, subtree: true });
4845 }
4846
4847 // Reset cleanly when a Svelte component session can't mount: tear the wrapper
4848 // down (restoring the original element), clear persisted session state, and
4849 // return the bar to PICKING. Avoids the stuck 0/0 CYCLING bar.
4850 function abortSvelteComponentInjection(sessionId, message) {
4851 try {
4852 if (svelteComponentSession?.sessionId === sessionId) {
4853 teardownSvelteComponentSession(true);
4854 } else {
4855 const orphan = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
4856 if (orphan) orphan.remove();
4857 }
4858 } catch (err) {
4859 console.warn('[impeccable] Svelte component abort cleanup failed:', err);
4860 }
4861 hideShaderOverlay();
4862 if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
4863 if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; }
4864 stopScrollLock();
4865 clearSession();
4866 clearHandled();
4867 resetSessionFileMeta();
4868 currentSessionId = null;
4869 expectedVariants = 0;
4870 arrivedVariants = 0;
4871 visibleVariant = 0;
4872 selectedElement = null;
4873 state = 'PICKING';
4874 hideBar();
4875 if (message) showToast(message, 5000);
4876 }
4877
4878 /**
4879 * No-HMR fallback: fetch the raw source file from the live server,
4880 * parse it, extract the variant wrapper, and inject it into the live DOM.
4881 * This works even when the dev server caches HTML (Bun, static servers).
4882 */
4883 function injectVariantsFromSource(filePath, sessionId) {
4884 if (isSvelteComponentManifestPath(filePath)) {
4885 injectSvelteComponentsFromManifest(filePath, sessionId);
4886 return;
4887 }
4888 rememberSessionFileMeta({ file: filePath });
4889 const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
4890 fetch(url)
4891 .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); })
4892 .then(html => {
4893 const parser = new DOMParser();
4894 let srcWrapper = null;
4895
4896 // Full-file parse works for HTML/JSX; Astro/Vue sources need marker extraction.
4897 const startMark = '<!-- impeccable-variants-start ' + sessionId + ' -->';
4898 const endMark = '<!-- impeccable-variants-end ' + sessionId + ' -->';
4899 const startIdx = html.indexOf(startMark);
4900 const endIdx = html.indexOf(endMark);
4901 const block = startIdx !== -1 && endIdx !== -1 && endIdx > startIdx
4902 ? html.slice(startIdx + startMark.length, endIdx).trim()
4903 : html;
4904 const doc = parser.parseFromString(block, 'text/html');
4905 srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]');
4906 if (!srcWrapper) {
4907 console.warn('[impeccable] Variant wrapper not found in source file.');
4908 return;
4909 }
4910
4911 const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0;
4912 const wrapper = srcWrapper.cloneNode(true);
4913
4914 // Wrapper already in DOM (wrap HMR landed, variant insert did not).
4915 const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
4916 if (existingWrapper) {
4917 existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
4918 } else {
4919 const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child');
4920 if (!origContent) return;
4921
4922 const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML);
4923 if (!liveEl) {
4924 console.warn('[impeccable] Could not find original element in live DOM.');
4925 selectedElement = document.body;
4926 recoveryWaitingForAnchor = true;
4927 state = 'GENERATING';
4928 showBar('generating');
4929 saveSession();
4930 showToast('Variants ready. Reveal the selected element to resume.', 15000);
4931 return;
4932 }
4933
4934 liveEl.parentElement.replaceChild(wrapper, liveEl);
4935 }
4936 recoveryWaitingForAnchor = false;
4937
4938 // Update state: count variants, preserving the user's current variant
4939 // when a late HMR/source reinjection lands after they have cycled.
4940 const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
4941 arrivedVariants = variants.length;
4942 expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);
4943 if (arrivedVariants <= 0) {
4944 recoverEmptyCycling('source-fallback-empty');
4945 return;
4946 }
4947 const saved = loadSession();
4948 const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
4949 visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants
4950 ? previousVisibleVariant
4951 : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
4952 showVariantInDOM(sessionId, visibleVariant);
4953
4954 // Update selectedElement to the visible variant's content
4955 selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement;
4956
4957 state = 'CYCLING';
4958 recoveryWaitingForAnchor = false;
4959 hideShaderOverlay();
4960 showOrUpdateCyclingBar();
4961 disableInlineEdit();
4962 refreshParamsPanel();
4963 positionBar();
4964 saveSession();
4965 console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
4966 })
4967 .catch(err => {
4968 console.error('[impeccable] Failed to fetch source:', err);
4969 showToast('Could not load variants. Try refreshing the page.', 5000);
4970 });
4971 }
4972
4973 function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) {
4974 const map = new Map();
4975 if (!sourceOriginal || !liveOriginal) return map;
4976
4977 const sourceNodes = collectTextNodes(sourceOriginal)
4978 .filter((node) => /\{[^{}]+\}/.test(node.nodeValue || ''));
4979 const liveTexts = collectTextNodes(liveOriginal)
4980 .map((node) => normalizePreviewText(node.nodeValue || ''))
4981 .filter(Boolean);
4982 let liveIndex = 0;
4983
4984 for (const sourceNode of sourceNodes) {
4985 const sourceText = sourceNode.nodeValue || '';
4986 const tokens = sourceText.match(/\{[^{}]+\}/g) || [];
4987 if (tokens.length === 0) continue;
4988
4989 const liveText = liveTexts[liveIndex++] || '';
4990 if (!liveText) continue;
4991
4992 if (tokens.length === 1) {
4993 const token = tokens[0];
4994 const normalizedSource = normalizePreviewText(sourceText);
4995 if (normalizedSource === token) {
4996 map.set(token, liveText);
4997 continue;
4998 }
4999
5000 const match = liveText.match(expressionTextMatcher(sourceText, [token]));
5001 if (match && match[1]) map.set(token, match[1].trim());
5002 continue;
5003 }
5004
5005 if (normalizePreviewText(sourceText) === tokens.join(' ')) {
5006 for (const token of tokens) {
5007 const tokenLiveText = liveTexts[liveIndex - 1] || '';
5008 if (tokenLiveText) map.set(token, tokenLiveText);
5009 }
5010 }
5011 }
5012
5013 return map;
5014 }
5015
5016 function expressionTextMatcher(sourceText, tokens) {
5017 let pattern = '^';
5018 let cursor = 0;
5019 for (const token of tokens) {
5020 const index = sourceText.indexOf(token, cursor);
5021 if (index === -1) continue;
5022 pattern += escapeRegExp(sourceText.slice(cursor, index)).replace(/\s+/g, '\\s*');
5023 pattern += '(.*?)';
5024 cursor = index + token.length;
5025 }
5026 pattern += escapeRegExp(sourceText.slice(cursor)).replace(/\s+/g, '\\s*') + '$';
5027 return new RegExp(pattern);
5028 }
5029
5030 function collectTextNodes(root) {
5031 if (!root) return [];
5032 const nodes = [];
5033 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
5034 let node = walker.nextNode();
5035 while (node) {
5036 nodes.push(node);
5037 node = walker.nextNode();
5038 }
5039 return nodes;
5040 }
5041
5042 function normalizePreviewText(value) {
5043 return String(value || '').replace(/\s+/g, ' ').trim();
5044 }
5045
5046 function escapeRegExp(value) {
5047 return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
5048 }
5049
5050 async function selectVariant(next, checkpointReason) {
5051 if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
5052 if (variantSelectionInFlight) return;
5053 if (next < 1 || next > arrivedVariants) return;
5054 if (next === visibleVariant) return;
5055
5056 const previous = visibleVariant;
5057 variantSelectionInFlight = true;
5058 const selectionPromise = (async () => {
5059 visibleVariant = next;
5060 showOrUpdateCyclingBar();
5061 saveSession();
5062 const shown = await showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
5063 if (!shown) {
5064 visibleVariant = previous;
5065 await showVariantInDOM(currentSessionId, previous);
5066 showOrUpdateCyclingBar();
5067 saveSession();
5068 return;
5069 }
5070 updateSelectedElement();
5071 showOrUpdateCyclingBar();
5072 positionBar();
5073 saveSession();
5074 if (checkpointReason) queueCheckpoint(checkpointReason);
5075 })();
5076 variantSelectionPromise = selectionPromise;
5077 try {
5078 await selectionPromise;
5079 } finally {
5080 if (variantSelectionPromise === selectionPromise) variantSelectionPromise = null;
5081 variantSelectionInFlight = false;
5082 }
5083 }
5084
5085 function cycleVariant(dir) {
5086 selectVariant(visibleVariant + dir, 'variant_changed');
5087 }
5088
5089 function updateSelectedElement() {
5090 if (!currentSessionId) return;
5091 if (svelteComponentSession?.sessionId === currentSessionId) {
5092 const anchor = resolveSvelteComponentAnchor();
5093 if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
5094 return;
5095 }
5096 const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
5097 if (!wrapper) return;
5098 const visEl = pickVariantContent(wrapper, visibleVariant);
5099 if (visEl) selectedElement = visEl;
5100 }
5101
5102 function readVisibleVariantFromDOM(sessionId) {
5103 if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
5104 return svelteComponentSession.mountedVariant;
5105 }
5106 const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
5107 if (!wrapper) return 0;
5108 const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
5109 for (const variant of variants) {
5110 if (!isVariantShown(variant)) continue;
5111 const idx = parseInt(variant.dataset.impeccableVariant || '0', 10);
5112 if (idx > 0) return idx;
5113 }
5114 return 0;
5115 }
5116
5117 // Resolve the element that represents the variant's visible content.
5118 // Contract: each variant div should contain exactly one top-level element
5119 // (the full replacement). In practice a model may ship loose siblings or
5120 // lead with <style>/<script>. Be defensive: skip non-visual elements, and
5121 // if the variant has multiple element children, use the variant div itself
5122 // (it wraps all of them and gets correct bounds).
5123 function pickVariantContent(wrapper, index) {
5124 if (!wrapper) return null;
5125 const variantDiv = wrapper.querySelector('[data-impeccable-variant="' + index + '"]');
5126 if (!variantDiv) return null;
5127 const NON_VISUAL = new Set(['STYLE', 'SCRIPT', 'LINK', 'META', 'TEMPLATE']);
5128 const visual = [];
5129 for (const child of variantDiv.children) {
5130 if (!NON_VISUAL.has(child.tagName)) visual.push(child);
5131 }
5132 if (visual.length === 1) return visual[0];
5133 return variantDiv;
5134 }
5135
5136 // Hold window.scrollY at a fixed value across DOM mutations inside the
5137 // session's wrapper (HMR patches, variant inserts, cycle swaps).
5138 function startScrollLock(sessionId, initialTargetY) {
5139 stopScrollLock();
5140 scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
5141 ? initialTargetY
5142 : window.scrollY;
5143
5144 try { history.scrollRestoration = 'manual'; } catch {}
5145
5146 const prevHtmlAnchor = document.documentElement.style.overflowAnchor;
5147 const prevBodyAnchor = document.body.style.overflowAnchor;
5148 document.documentElement.style.overflowAnchor = 'none';
5149 document.body.style.overflowAnchor = 'none';
5150
5151 const correct = (why) => {
5152 scrollLockRaf = null;
5153 if (scrollLockTargetY == null) return;
5154 const before = window.scrollY;
5155 const delta = before - scrollLockTargetY;
5156 if (Math.abs(delta) < 0.5) {
5157 return;
5158 }
5159 window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
5160 };
5161 const schedule = (why) => {
5162 if (scrollLockRaf != null) return;
5163 scrollLockRaf = requestAnimationFrame(() => correct(why));
5164 };
5165
5166 scrollLockObserver = new MutationObserver((mutations) => {
5167 for (const m of mutations) {
5168 if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) {
5169 schedule('mutation-in-wrapper');
5170 return;
5171 }
5172 for (const n of m.addedNodes) {
5173 if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) {
5174 schedule('wrapper-added');
5175 return;
5176 }
5177 }
5178 }
5179 });
5180 scrollLockObserver.observe(document.body, { childList: true, subtree: true });
5181
5182 scrollLockAbort = new AbortController();
5183 scrollLockAbort.signal.addEventListener('abort', () => {
5184 document.documentElement.style.overflowAnchor = prevHtmlAnchor;
5185 document.body.style.overflowAnchor = prevBodyAnchor;
5186 }, { once: true });
5187 const sig = { signal: scrollLockAbort.signal };
5188 // Track whether the most recent scroll came from a user gesture. We
5189 // gate user-scroll re-anchoring on this flag so programmatic smooth
5190 // scrolls (browser reload-restore, scrollIntoView from other scripts)
5191 // don't accidentally update our target.
5192 let userGestureAt = 0;
5193 const USER_GESTURE_WINDOW_MS = 250;
5194
5195 const reanchor = (why) => {
5196 if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
5197 const prevTarget = scrollLockTargetY;
5198 scrollLockTargetY = window.scrollY;
5199 writeScrollY(scrollLockTargetY);
5200 };
5201 const markGesture = (why) => {
5202 userGestureAt = performance.now();
5203 reanchor(why);
5204 };
5205 window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });
5206 window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });
5207 window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });
5208 window.addEventListener('keydown', (e) => {
5209 if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);
5210 }, sig);
5211
5212 // Correct on EVERY scroll event: whether it's the browser's
5213 // post-reload animated restore or some other script calling
5214 // scrollIntoView, we want to snap back immediately. Only skip if a
5215 // user gesture fired in the last 250ms.
5216 window.addEventListener('scroll', () => {
5217 const now = window.scrollY;
5218 if (scrollLockTargetY == null) return;
5219 if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;
5220 if (Math.abs(now - scrollLockTargetY) < 0.5) return;
5221 window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
5222 }, { passive: true, ...sig });
5223
5224 // Apply target synchronously, not via rAF - racing the browser's
5225 // restore or a smooth-scroll animation means we want to win now.
5226 if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
5227 window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
5228 }
5229 }
5230
5231 function stopScrollLock() {
5232 if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
5233 if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
5234 if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
5235 scrollLockTargetY = null;
5236 // NOTE: do NOT clear the persistent scroll key here. startScrollLock
5237 // calls us as a reset, and clearing the key would nuke the Go-time
5238 // scrollY that the next resume needs to read.
5239 }
5240
5241 //
5242 // MutationObserver for progressive variant reveal
5243 //
5244
5245 function startVariantObserver(sessionId) {
5246 let updating = false; // re-entrancy guard
5247
5248 const obs = new MutationObserver((mutations) => {
5249 if (updating) return;
5250
5251 // Only react to mutations that add nodes with data-impeccable-variant,
5252 // or mutations inside the variant wrapper. Ignore our own bar/UI changes.
5253 let dominated = false;
5254 for (const m of mutations) {
5255 if (m.target.closest?.('[data-impeccable-variants]')) { dominated = true; break; }
5256 for (const n of m.addedNodes) {
5257 if (n.nodeType !== 1) continue;
5258 // Direct hit: the added node itself is the wrapper or a variant.
5259 if (n.dataset?.impeccableVariants || n.dataset?.impeccableVariant) {
5260 dominated = true; break;
5261 }
5262 // Subtree hit: framework HMR (notably SvelteKit) sometimes replaces
5263 // a whole subtree where the wrapper is a descendant of the added
5264 // node. Without this check, the observer ignores those mutations
5265 // and the session stays in GENERATING forever.
5266 if (n.querySelector?.('[data-impeccable-variants],[data-impeccable-variant]')) {
5267 dominated = true; break;
5268 }
5269 }
5270 if (dominated) break;
5271 }
5272 if (!dominated) return;
5273
5274 const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
5275 if (!wrapper) return;
5276
5277 const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
5278 const count = variants.length;
5279
5280 // Re-anchor selectedElement if it was detached by live-wrap's HMR swap.
5281 // Without this, the shader / highlight / bar track a zero-rect phantom
5282 // and the overlay appears frozen.
5283 if (selectedElement && !document.body.contains(selectedElement)) {
5284 const isInsert = wrapper.dataset.impeccableMode === 'insert';
5285 if (isInsert) {
5286 const visEl = count > 0 ? pickVariantContent(wrapper, visibleVariant || 1) : null;
5287 if (visEl) {
5288 selectedElement = visEl;
5289 if (count > 0) removeInsertPlaceholderDom();
5290 } else {
5291 const ph = ensureInsertPlaceholder();
5292 if (ph) selectedElement = ph;
5293 else if (insertAnchorElement && document.body.contains(insertAnchorElement)) {
5294 selectedElement = insertAnchorElement;
5295 }
5296 }
5297 } else {
5298 selectedElement = pickVariantContent(wrapper, 'original') || wrapper;
5299 }
5300 } else if (isInsertGeneratingSession() && count === 0) {
5301 ensureInsertPlaceholder();
5302 }
5303
5304 // Nothing new
5305 if (count <= arrivedVariants) return;
5306
5307 updating = true;
5308 arrivedVariants = count;
5309 if (visibleVariant === 0 && arrivedVariants > 0) {
5310 const saved = loadSession();
5311 const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
5312 visibleVariant = savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1;
5313 showVariantInDOM(sessionId, visibleVariant);
5314 // showVariantInDOM hid the original (display:none); if we were still
5315 // anchored to the original's content, its boundingRect is now zero
5316 // and the bar snaps to (0,0). Re-point at the visible variant instead.
5317 const visEl = pickVariantContent(wrapper, visibleVariant);
5318 if (visEl) selectedElement = visEl;
5319 }
5320
5321 const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
5322 if (expected > 0) expectedVariants = expected;
5323
5324 if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
5325 state = 'CYCLING';
5326 recoveryWaitingForAnchor = false;
5327 hideShaderOverlay();
5328 if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession();
5329 updateSelectedElement();
5330 showOrUpdateCyclingBar();
5331 disableInlineEdit();
5332 refreshParamsPanel();
5333 positionBar();
5334 } else if (state === 'GENERATING') {
5335 updateBarContent('generating');
5336 }
5337 saveSession();
5338 queueCheckpoint(state === 'CYCLING' ? 'variants_ready' : 'variants_progress');
5339 updating = false;
5340 });
5341
5342 obs.observe(document.body, { childList: true, subtree: true });
5343 return obs;
5344 }
5345
5346 //
5347 // Bar scroll tracking
5348 //
5349
5350 function startScrollTracking() {
5351 function tick() {
5352 if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
5353 if (isInsertGeneratingSession()) ensureInsertPlaceholder();
5354 positionBar();
5355 if (state === 'CONFIGURING') positionEditBadge();
5356 const hiTarget = resolveBarAnchor();
5357 if (hiTarget && !hiTarget.hasAttribute?.('data-impeccable-insert-placeholder')) {
5358 showHighlight(hiTarget);
5359 } else {
5360 hideHighlight();
5361 }
5362 if (tuneOpen) positionParamsPanel();
5363 }
5364 if (state === 'EDITING') {
5365 positionEditBadge();
5366 showHighlight(selectedElement);
5367 }
5368 if (annotActive) {
5369 const annotTarget = resolveBarAnchor();
5370 if (annotTarget) positionAnnotOverlay(annotTarget);
5371 }
5372 // Shader overlay (via debug P toggle or generation) is repositioned
5373 // by its own branch below; debug no longer has a separate overlay.
5374 if (shaderState) positionShaderOverlay();
5375 scrollRaf = requestAnimationFrame(tick);
5376 }
5377 scrollRaf = requestAnimationFrame(tick);
5378 }
5379
5380 function stopScrollTracking() {
5381 if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; }
5382 }
5383
5384 //
5385 // SSE (server→browser) + fetch POST (browser→server)
5386 // Zero-dependency replacement for WebSocket.
5387 //
5388
5389 let evtSource = null;
5390 let sseRetries = 0;
5391 const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
5392
5393 function connectSSE() {
5394 evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
5395
5396 evtSource.onopen = () => {
5397 sseRetries = 0; // reset on successful (re)connect
5398 };
5399
5400 evtSource.onmessage = (e) => {
5401 sseRetries = 0; // reset on any successful message
5402 let msg; try { msg = JSON.parse(e.data); } catch { return; }
5403 switch (msg.type) {
5404 case 'connected':
5405 hasProjectContext = !!msg.hasProjectContext;
5406 if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable init to generate one.', 7000);
5407 console.log('[impeccable] Live mode connected.');
5408 syncAgentPollingUi(!!msg.agentPolling);
5409 startAgentStatusPoll();
5410 restoreFromActiveSessions(msg.activeSessions, 'sse_connected');
5411 if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING';
5412 syncPageChatFocus('sse-connected');
5413 break;
5414 case 'agent_polling':
5415 syncAgentPollingUi(!!msg.connected);
5416 break;
5417 case 'steer_done':
5418 maybeCompleteSteer(msg);
5419 break;
5420 case 'manual_edit_stashed':
5421 case 'manual_edit_discarded':
5422 case 'manual_edit_commit_started':
5423 case 'manual_edit_apply_reply_received':
5424 case 'manual_edit_apply_dispatched':
5425 case 'manual_edit_repair_needs_decision':
5426 case 'manual_edit_repair_rollback_done':
5427 case 'manual_edit_commit_done':
5428 case 'manual_edit_commit_failed':
5429 handleManualEditActivity(msg);
5430 break;
5431 case 'done':
5432 if (maybeCompleteSteer(msg)) break;
5433 rememberSessionFileMeta(msg);
5434 // Variants already arrived via HMR → normal transition.
5435 if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
5436 if (state === 'GENERATING') {
5437 state = 'CYCLING';
5438 showOrUpdateCyclingBar();
5439 disableInlineEdit();
5440 refreshParamsPanel();
5441 }
5442 break;
5443 }
5444 // Source fallback when HMR did not land variants in this tab.
5445 if (msg.file && msg.id && state === 'GENERATING' && msg.id === currentSessionId) {
5446 injectVariantsFromSource(msg.file, msg.id);
5447 break;
5448 }
5449 // Variants are in source but not in the DOM yet. Common when the
5450 // picked element lived inside conditional render (closed modal,
5451 // hidden tab, a route the user navigated away from). The variant
5452 // MutationObserver stays armed and auto-transitions to CYCLING
5453 // the moment the wrapper actually mounts. Nudge the user toward
5454 // that path with a toast - better than the prior force-reload
5455 // which reset framework state and left the session stuck.
5456 setTimeout(() => {
5457 if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
5458 if (state !== 'GENERATING') return;
5459 showToast(
5460 "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.",
5461 15000,
5462 );
5463 }, 2000);
5464 break;
5465 case 'complete':
5466 case 'accept':
5467 if (maybeCompleteAcceptedSession(msg)) break;
5468 break;
5469 case 'agent_done':
5470 // Carbonize accepts are not terminal until live-complete.mjs sends
5471 // the final complete event. Keep the browser in its recoverable
5472 // saving state while the source cleanup is still in flight.
5473 break;
5474 case 'discarded':
5475 if (msg.id && msg.id === currentSessionId) {
5476 markSessionHandled();
5477 cleanup();
5478 }
5479 break;
5480 case 'error':
5481 if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) {
5482 pendingAcceptedSession = null;
5483 state = 'CYCLING';
5484 updateBarContent('cycling');
5485 showToast('Could not complete accept cleanup. Try Accept again.', 5000);
5486 break;
5487 }
5488 if (maybeCompleteSteer(msg)) break;
5489 console.error('[impeccable] Error:', msg.message);
5490 showToast('Error: ' + msg.message, 5000);
5491 hideBar();
5492 renderEditBadge('hidden');
5493 state = 'PICKING';
5494 break;
5495 }
5496 };
5497
5498 evtSource.onerror = () => {
5499 sseRetries++;
5500 if (sseRetries <= SSE_MAX_RETRIES) {
5501 console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
5502 return; // EventSource auto-reconnects
5503 }
5504 // Server is gone. Clean up gracefully.
5505 console.log('[impeccable] Live server unreachable. Cleaning up UI.');
5506 evtSource.close();
5507 evtSource = null;
5508 handleServerLost();
5509 };
5510 }
5511
5512 /** Server died or became unreachable. Reset UI to a clean state. */
5513 function handleServerLost() {
5514 const recoveryState = currentSessionId ? state : 'IDLE';
5515 if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
5516 showToast('Live server disconnected. Session ended.', 5000);
5517 }
5518 hideBar();
5519 hideHighlight();
5520 hideShaderOverlay();
5521 hideAnnotOverlay();
5522 stopScrollTracking();
5523 if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
5524 stopScrollLock();
5525 // Preserve local session state on server loss. The durable journal is the
5526 // source of truth, but localStorage plus the variant wrapper lets the UI
5527 // resume after a helper restart or page reload instead of treating a
5528 // transient disconnect as an explicit discard.
5529 selectedElement = null;
5530 selectedAction = 'impeccable';
5531 state = recoveryState;
5532 if (currentSessionId) saveSession();
5533 }
5534
5535 function sendEvent(msg, opts) {
5536 msg.token = TOKEN;
5537 function handleFailure(err) {
5538 console.error('[impeccable] Failed to send event:', err);
5539 if (opts && opts.throwOnError) throw err;
5540 return null;
5541 }
5542 return fetch('http://localhost:' + PORT + '/events', {
5543 method: 'POST',
5544 headers: { 'Content-Type': 'application/json' },
5545 body: JSON.stringify(msg),
5546 }).then(async res => {
5547 if (res.ok) return res;
5548 const body = await res.json().catch(() => ({}));
5549 return handleFailure(new Error(body.error || ('HTTP ' + res.status + ' ' + res.statusText)));
5550 }).catch(handleFailure);
5551 }
5552
5553 function checkpointPayload(reason) {
5554 return {
5555 type: 'checkpoint',
5556 id: currentSessionId,
5557 revision: sessionState.nextCheckpointRevision(),
5558 owner: browserOwner,
5559 phase: String(state || '').toLowerCase(),
5560 reason,
5561 pageUrl: location.pathname,
5562 expectedVariants,
5563 arrivedVariants,
5564 visibleVariant,
5565 sourceFile: currentSourceFile || undefined,
5566 previewFile: currentPreviewFile || undefined,
5567 previewMode: currentPreviewMode || undefined,
5568 paramValues: { ...paramsCurrentValues },
5569 };
5570 }
5571
5572 function sendCheckpoint(reason) {
5573 if (!currentSessionId) return Promise.resolve(null);
5574 return sendEvent(checkpointPayload(reason)).catch(() => null);
5575 }
5576
5577 function sendSteerCheckpoint(id, reason, extra) {
5578 if (!id) return Promise.resolve(null);
5579 return sendEvent({
5580 type: 'checkpoint',
5581 id,
5582 revision: sessionState.nextCheckpointRevision(),
5583 owner: browserOwner,
5584 phase: 'steer',
5585 reason,
5586 pageUrl: location.pathname,
5587 ...(extra || {}),
5588 }).catch(() => null);
5589 }
5590
5591 function queueCheckpoint(reason) {
5592 if (!currentSessionId) return;
5593 if (checkpointTimer) clearTimeout(checkpointTimer);
5594 checkpointTimer = setTimeout(() => {
5595 checkpointTimer = null;
5596 sendCheckpoint(reason);
5597 }, 120);
5598 }
5599
5600 //
5601 // Event handlers
5602 //
5603
5604 function handleMouseMove(e) {
5605 if (pendingApplyInFlight) return;
5606 if (state === 'PICKING' && insertActive) {
5607 const target = document.elementFromPoint(e.clientX, e.clientY);
5608 if (!target || own(target) || !pickable(target)) {
5609 hideInsertLine();
5610 return;
5611 }
5612 const parent = target.parentElement;
5613 const axis = detectInsertAxis(parent);
5614 const siblings = layoutFlowChildren(parent);
5615 const rect = target.getBoundingClientRect();
5616 const resolved = resolveInsertHover({
5617 clientX: e.clientX,
5618 clientY: e.clientY,
5619 target,
5620 rect,
5621 axis,
5622 siblings,
5623 });
5624 if (
5625 resolved.anchor !== insertHoverAnchor
5626 || resolved.position !== insertHoverPosition
5627 || resolved.axis !== insertHoverAxis
5628 ) {
5629 showInsertLine(resolved);
5630 }
5631 syncPageInteractionCursor();
5632 return;
5633 }
5634 if (state !== 'PICKING' || !pickActive) return;
5635 const target = document.elementFromPoint(e.clientX, e.clientY);
5636 if (!target || !pickable(target) || target === hoveredElement) return;
5637 hoveredElement = target;
5638 showHighlight(target);
5639 }
5640
5641 function handleClick(e) {
5642 if (pendingApplyInFlight && !pendingDockEl?.contains(e.target)) {
5643 if (pickerEl?.style.display !== 'none') hideActionPicker();
5644 if (own(e.target)) {
5645 e.preventDefault();
5646 e.stopPropagation();
5647 showManualApplyBusyToast();
5648 }
5649 return;
5650 }
5651 // Close action picker on any outside click
5652 if (pickerEl?.style.display !== 'none' && !own(e.target)) {
5653 hideActionPicker();
5654 }
5655 // Close Tune popover on outside click (anything outside panel + bar)
5656 if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
5657 closeTunePopover();
5658 }
5659 // In EDITING: click outside exits the text edit flow without rebuilding configure UI first.
5660 if (state === 'EDITING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
5661 cancelEditingToPicking();
5662 return;
5663 }
5664 // In CONFIGURING: click outside the bar and selected element returns to PICKING.
5665 if (
5666 state === 'CONFIGURING' && !own(e.target) && selectedElement
5667 && !selectedElement.contains(e.target)
5668 ) {
5669 if (configureKind === 'insert') { cancelInsertConfigure(); return; }
5670 hideBar();
5671 stopScrollTracking();
5672 hideAnnotOverlay();
5673 clearAnnotations();
5674 renderEditBadge('hidden');
5675 state = 'PICKING';
5676 hoveredElement = null;
5677 hideHighlight();
5678 syncPageChatFocus('configure-outside-click');
5679 return;
5680 }
5681 if (state === 'PICKING' && insertActive) {
5682 if (own(e.target)) return;
5683 if (!insertHoverAnchor || !insertHoverPosition) return;
5684 e.preventDefault();
5685 e.stopPropagation();
5686 const placeholder = createInsertPlaceholder(
5687 insertHoverAnchor,
5688 insertHoverPosition,
5689 insertHoverAxis,
5690 );
5691 if (!placeholder) return;
5692 hideInsertLine();
5693 configureKind = 'insert';
5694 selectedElement = placeholder;
5695 state = 'CONFIGURING';
5696 hideHighlight();
5697 clearAnnotations();
5698 showAnnotOverlay(placeholder);
5699 showBar('configure');
5700 startScrollTracking();
5701 syncPageInteractionCursor();
5702 return;
5703 }
5704 if (state !== 'PICKING' || !pickActive) return;
5705 if (own(e.target)) return;
5706 if (pagePickSkipClick || pageHasHostTextSelection()) {
5707 pagePickSkipClick = false;
5708 return;
5709 }
5710 if (!hoveredElement || !pickable(hoveredElement)) return;
5711 e.preventDefault();
5712 e.stopPropagation();
5713 selectedElement = hoveredElement;
5714 state = 'CONFIGURING';
5715 showHighlight(selectedElement);
5716 clearAnnotations();
5717 showAnnotOverlay(selectedElement);
5718 showBar('configure');
5719 renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden');
5720 startScrollTracking();
5721 maybePrefetchPage();
5722 maybeWarnConditionalAncestor(selectedElement);
5723 }
5724
5725 /**
5726 * Surface a brief, non-blocking heads-up when the picked element lives
5727 * inside a container whose visibility is gated by ephemeral state - modals,
5728 * collapsible panels, popovers, off-screen tab panels. If HMR remounts the
5729 * parent during generation (Vite Fast Refresh, SvelteKit page reload), the
5730 * variants land in source but stay invisible until the user re-opens the
5731 * container. Telling the user upfront is much friendlier than the silent
5732 * timeout-then-toast that they'd otherwise hit.
5733 *
5734 * Heuristic, intentionally narrow - only fires for unambiguous cases so
5735 * we don't cry wolf on every nested element.
5736 */
5737 function maybeWarnConditionalAncestor(el) {
5738 let node = el?.parentElement;
5739 let depth = 0;
5740 while (node && depth < 12) {
5741 // 1. Active dialog / modal
5742 if (node.getAttribute && node.getAttribute('role') === 'dialog'
5743 && node.getAttribute('aria-modal') === 'true') {
5744 showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
5745 return;
5746 }
5747 // 2. Common Radix / shadcn / headless-ui open-state attribute
5748 if (node.dataset && node.dataset.state === 'open') {
5749 showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
5750 return;
5751 }
5752 // 3. Tab panel - only meaningful when the page also shows ANOTHER
5753 // tab as selected. A single tabpanel with no tablist is just a static
5754 // section in disguise and isn't conditional.
5755 if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
5756 const list = document.querySelector('[role="tablist"]');
5757 if (list) {
5758 const tabs = list.querySelectorAll('[role="tab"]');
5759 if (tabs.length > 1) {
5760 showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);
5761 return;
5762 }
5763 }
5764 }
5765 // 4. Collapsible: aria-expanded sibling. Look for the trigger button.
5766 if (node.id) {
5767 const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);
5768 if (trigger) {
5769 showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);
5770 return;
5771 }
5772 }
5773 node = node.parentElement;
5774 depth++;
5775 }
5776 }
5777
5778 // Fire a lightweight prefetch event the first time the user selects an
5779 // element on a given route. The agent uses this to Read the underlying file
5780 // into context before Go is hit, shaving the read off the critical path.
5781 // Dedupe per session by pathname - clicking around on the same page doesn't
5782 // re-fire.
5783 //
5784 // DISABLED: quick-Go workflows pay an extra harness round trip because
5785 // prefetch + generate arrive as two events instead of one. Re-enable with
5786 // a browser-side debounce (~800-1000ms, cancelled on Go) if we want to
5787 // resurrect this. Server validator and skill dispatch remain in place so
5788 // flipping this flag is the only change needed.
5789 const PREFETCH_ENABLED = false;
5790 const prefetchedPaths = new Set();
5791 function maybePrefetchPage() {
5792 if (!PREFETCH_ENABLED) return;
5793 const path = location.pathname;
5794 if (prefetchedPaths.has(path)) return;
5795 prefetchedPaths.add(path);
5796 sendEvent({ type: 'prefetch', pageUrl: path });
5797 }
5798
5799 function handleKeyDown(e) {
5800 // When the annotation input is focused, let it handle its own keys.
5801 if (annotEditing && annotEditing.input && e.target === annotEditing.input) return;
5802 const deepActive = activeElementDeep();
5803 if (
5804 deepActive
5805 && own(deepActive)
5806 && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '')
5807 ) {
5808 return;
5809 }
5810 // While a contenteditable text-leaf is focused, let the browser handle
5811 // all keys except Escape. Escape cancels the current edit (restores
5812 // original text) and blurs without saving, staying in CONFIGURING.
5813 if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
5814 if (e.key !== 'Escape') return;
5815 e.preventDefault();
5816 e.stopPropagation();
5817 const original = e.target.dataset.impeccableOriginalText;
5818 if (original !== undefined) e.target.textContent = original;
5819 // Programmatic textContent doesn't fire the 'input' event, so the draft
5820 // map would otherwise hold the pre-cancel value and Apply would commit
5821 // changes the user explicitly undid.
5822 inlineEditDrafts.delete(e.target);
5823 e.target.blur();
5824 return;
5825 }
5826 if (pendingApplyInFlight) {
5827 const liveNavKey = e.key === 'Enter'
5828 || e.key === 'ArrowUp'
5829 || e.key === 'ArrowDown'
5830 || e.key === 'ArrowLeft'
5831 || e.key === 'ArrowRight';
5832 if (liveNavKey && (state === 'PICKING' || state === 'CONFIGURING' || state === 'CYCLING')) {
5833 e.preventDefault();
5834 e.stopPropagation();
5835 if (e.key === 'Enter') showManualApplyBusyToast();
5836 }
5837 return;
5838 }
5839 if (e.key === 'Escape') {
5840 e.preventDefault();
5841 if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; }
5842 if (state === 'EDITING') { cancelEditing(); return; }
5843 if (state === 'CONFIGURING') {
5844 if (configureKind === 'insert') { cancelInsertConfigure(); return; }
5845 disableInlineEdit(); hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); renderEditBadge('hidden'); state = 'PICKING'; syncPageChatFocus('escape-from-configure'); return;
5846 }
5847 if (state === 'CYCLING') { handleDiscard(); return; }
5848 if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt
5849 if (state === 'PICKING') {
5850 if (insertActive) toggleInsert();
5851 else if (pickActive) togglePick();
5852 else { hideHighlight(); state = 'IDLE'; }
5853 return;
5854 }
5855 }
5856
5857 // Arrow/Enter nav works in PICKING (hover) and CONFIGURING (selected, input empty)
5858 var navEl = (state === 'PICKING') ? hoveredElement : (state === 'CONFIGURING') ? selectedElement : null;
5859 if (navEl && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || (e.key === 'Enter' && state === 'PICKING'))) {
5860 let next = null;
5861 if (e.key === 'ArrowDown' && !e.shiftKey) {
5862 next = navEl.nextElementSibling;
5863 while (next && !pickable(next)) next = next.nextElementSibling;
5864 } else if (e.key === 'ArrowUp' && !e.shiftKey) {
5865 next = navEl.previousElementSibling;
5866 while (next && !pickable(next)) next = next.previousElementSibling;
5867 } else if (e.key === 'ArrowUp' && e.shiftKey) {
5868 next = navEl.parentElement;
5869 if (next && !pickable(next)) next = null;
5870 } else if (e.key === 'ArrowDown' && e.shiftKey) {
5871 next = navEl.firstElementChild;
5872 while (next && !pickable(next)) next = next.nextElementSibling;
5873 } else if (e.key === 'Enter') {
5874 e.preventDefault();
5875 selectedElement = hoveredElement;
5876 state = 'CONFIGURING';
5877 showHighlight(selectedElement);
5878 clearAnnotations();
5879 showAnnotOverlay(selectedElement);
5880 showBar('configure');
5881 renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden');
5882 startScrollTracking();
5883 return;
5884 }
5885 if (next) {
5886 e.preventDefault();
5887 if (state === 'PICKING') {
5888 hoveredElement = next;
5889 } else {
5890 // CONFIGURING: re-select the new element
5891 selectedElement = next;
5892 clearAnnotations();
5893 showAnnotOverlay(next);
5894 showBar('configure');
5895 disableInlineEdit();
5896 renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden');
5897 startScrollTracking();
5898 }
5899 showHighlight(next);
5900 next.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
5901 }
5902 return;
5903 }
5904
5905 if (state === 'CYCLING') {
5906 if (e.key === 'ArrowLeft') { e.preventDefault(); cycleVariant(-1); }
5907 if (e.key === 'ArrowRight') { e.preventDefault(); cycleVariant(1); }
5908 if (e.key === 'Enter') { e.preventDefault(); handleAccept(); }
5909 }
5910 }
5911
5912 function handleGo() {
5913 if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
5914 if (!selectedElement || state !== 'CONFIGURING') return;
5915 stopVoice({ suppressSubmit: true });
5916 const input = uiGetById(PREFIX + '-input');
5917 const prompt = input ? input.value.trim() : '';
5918
5919 // Commit any pending pin edit BEFORE we snapshot annotations.
5920 if (annotEditing) finalizeEditingPin();
5921 // Go captures page content, not manual-edit runtime state.
5922 disableInlineEdit();
5923 stripManualEditRuntimeState(selectedElement);
5924
5925 pendingAcceptedSession = null;
5926 currentSessionId = id8();
5927 expectedVariants = selectedCount;
5928 arrivedVariants = 0;
5929 visibleVariant = 0;
5930 resetSessionFileMeta();
5931
5932 // Flip to GENERATING immediately so the bar morphs without waiting on
5933 // capture + upload. The event is emitted from captureAndEmit() once the
5934 // screenshot is uploaded (or capture fails - we still emit, just without
5935 // screenshotPath).
5936 const elForCapture = selectedElement;
5937 const captureRect = elForCapture.getBoundingClientRect();
5938 const snapshot = {
5939 comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })),
5940 strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })),
5941 };
5942 const basePayload = {
5943 type: 'generate', id: currentSessionId,
5944 action: selectedAction,
5945 freeformPrompt: prompt || undefined,
5946 count: selectedCount,
5947 pageUrl: location.pathname,
5948 element: extractContext(elForCapture),
5949 };
5950 if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
5951 if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
5952
5953 // Hide the interactive overlay so it doesn't linger during generation.
5954 hideAnnotOverlay();
5955 clearAnnotations();
5956
5957 state = 'GENERATING';
5958 // Disable the Edit badge: starting a manual text edit mid-generation would
5959 // conflict with the variant wrap that's about to land in the same DOM
5960 // region. Only swap if the badge was visible - picked elements with no
5961 // text rows have it hidden already.
5962 if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
5963 showBar('generating');
5964 saveSession();
5965 sendCheckpoint('generate_started');
5966 writeScrollY(window.scrollY);
5967 if (variantObserver) variantObserver.disconnect();
5968 variantObserver = startVariantObserver(currentSessionId);
5969 startScrollLock(currentSessionId);
5970
5971 captureAndEmit(elForCapture, basePayload, snapshot, captureRect);
5972 }
5973
5974 function cancelInsertConfigure() {
5975 hideBar();
5976 stopScrollTracking();
5977 hideAnnotOverlay();
5978 clearAnnotations();
5979 clearInsertPicking();
5980 configureKind = 'replace';
5981 selectedElement = null;
5982 state = insertActive ? 'PICKING' : 'IDLE';
5983 hideHighlight();
5984 syncPageChatFocus('insert-configure-cancel');
5985 }
5986
5987 function handleInsertCreate() {
5988 if (!placeholderElement || !insertAnchorElement || state !== 'CONFIGURING' || configureKind !== 'insert') return;
5989 const input = uiGetById(PREFIX + '-insert-input');
5990 const prompt = input ? input.value.trim() : '';
5991 if (annotEditing) finalizeEditingPin();
5992 const snapshot = {
5993 comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })),
5994 strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })),
5995 };
5996 if (!canCreateInsert({ prompt, comments: snapshot.comments, strokes: snapshot.strokes })) return;
5997
5998 stopVoice({ suppressSubmit: true });
5999 pendingAcceptedSession = null;
6000 currentSessionId = id8();
6001 expectedVariants = selectedCount;
6002 arrivedVariants = 0;
6003 visibleVariant = 0;
6004 resetSessionFileMeta();
6005 selectedElement = placeholderElement;
6006 insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement);
6007
6008 const elForCapture = placeholderElement;
6009 const captureRect = elForCapture.getBoundingClientRect();
6010 const basePayload = {
6011 type: 'generate',
6012 mode: 'insert',
6013 id: currentSessionId,
6014 count: selectedCount,
6015 pageUrl: location.pathname,
6016 insert: {
6017 position: insertAnchorPosition,
6018 anchor: extractContext(insertAnchorElement),
6019 },
6020 placeholder: {
6021 width: Math.round(captureRect.width),
6022 height: Math.round(captureRect.height),
6023 },
6024 freeformPrompt: prompt || undefined,
6025 };
6026 if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
6027 if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
6028
6029 hideAnnotOverlay();
6030 clearAnnotations();
6031
6032 state = 'GENERATING';
6033 showBar('generating');
6034 startScrollTracking();
6035 saveSession();
6036 sendCheckpoint('generate_started');
6037 writeScrollY(window.scrollY);
6038 if (variantObserver) variantObserver.disconnect();
6039 variantObserver = startVariantObserver(currentSessionId);
6040 startScrollLock(currentSessionId);
6041 captureAndEmit(elForCapture, basePayload, snapshot, captureRect);
6042 }
6043
6044 //
6045 // Screenshot capture + upload
6046 //
6047
6048 let msLoadPromise = null;
6049 function loadModernScreenshot() {
6050 if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot);
6051 if (msLoadPromise) return msLoadPromise;
6052 msLoadPromise = new Promise((resolve, reject) => {
6053 const s = document.createElement('script');
6054 s.src = 'http://localhost:' + PORT + '/modern-screenshot.js';
6055 s.onload = () => resolve(window.modernScreenshot);
6056 s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); };
6057 uiAppendStyle(s);
6058 });
6059 return msLoadPromise;
6060 }
6061
6062 // Collect @font-face rules from every stylesheet on the page. Cross-origin
6063 // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
6064 // access, so modern-screenshot can't embed them on its own - the resulting
6065 // SVG falls back to system fonts and text re-wraps + renders with different
6066 // weight. We fetch the raw CSS text (CORS-permitted for these providers),
6067 // extract @font-face blocks, inline the referenced font files as base64
6068 // data URIs (SVGs rasterized via canvas can't fetch external resources,
6069 // so URLs inside the SVG silently fail without this), and pass the result
6070 // to modern-screenshot as font.cssText.
6071 const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i;
6072 const FONT_MIME = {
6073 woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject',
6074 };
6075 function bufferToBase64(buf) {
6076 const bytes = new Uint8Array(buf);
6077 let binary = '';
6078 const CHUNK = 0x8000;
6079 for (let i = 0; i < bytes.length; i += CHUNK) {
6080 binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
6081 }
6082 return btoa(binary);
6083 }
6084 async function inlineFontUrls(cssText) {
6085 const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g;
6086 const urls = new Set();
6087 let m;
6088 while ((m = urlRe.exec(cssText))) {
6089 if (FONT_EXT_RE.test(m[2])) urls.add(m[2]);
6090 }
6091 const map = new Map();
6092 await Promise.all([...urls].map(async (url) => {
6093 try {
6094 const res = await fetch(url);
6095 if (!res.ok) return;
6096 const buf = await res.arrayBuffer();
6097 const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2';
6098 const mime = FONT_MIME[ext] || 'application/octet-stream';
6099 map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf));
6100 } catch { /* skip; fall through to URL */ }
6101 }));
6102 return cssText.replace(urlRe, (orig, q, url) => {
6103 const data = map.get(url);
6104 return data ? 'url(' + q + data + q + ')' : orig;
6105 });
6106 }
6107 async function collectFontCssText() {
6108 const chunks = [];
6109 const fontFaceRe = /@font-face\s*\{[^}]*\}/g;
6110 for (const sheet of document.styleSheets) {
6111 try {
6112 const rules = sheet.cssRules;
6113 for (const rule of rules) {
6114 if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) {
6115 chunks.push(rule.cssText);
6116 }
6117 }
6118 } catch {
6119 if (!sheet.href) continue;
6120 try {
6121 const res = await fetch(sheet.href);
6122 if (!res.ok) continue;
6123 const text = await res.text();
6124 let m2;
6125 while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]);
6126 } catch { /* ignore; capture is best-effort */ }
6127 }
6128 }
6129 if (chunks.length === 0) return '';
6130 return inlineFontUrls(chunks.join('\n'));
6131 }
6132
6133 // True if `s` is a computed color string that renders as nothing
6134 // (explicit `transparent`, or `rgba(...)` with alpha 0).
6135 function isTransparentColor(s) {
6136 if (!s) return true;
6137 if (s === 'transparent') return true;
6138 const m = /rgba?\(([^)]+)\)/.exec(s);
6139 if (!m) return false;
6140 const parts = m[1].split(',').map((p) => p.trim());
6141 if (parts.length === 4) return parseFloat(parts[3]) === 0;
6142 return false;
6143 }
6144
6145 // modern-screenshot force-sets `background-color: X !important` on the
6146 // cloned root whenever `backgroundColor` is passed, clobbering the
6147 // element's own background. So we only pass it when the element is
6148 // genuinely transparent (no own color, no own image) - in that case
6149 // we resolve up the DOM to the nearest opaque ancestor so the capture
6150 // sits on the page's real background instead of rendering black.
6151 function resolveCanvasBackground(el) {
6152 const own = getComputedStyle(el);
6153 if (!isTransparentColor(own.backgroundColor)) return null;
6154 if (own.backgroundImage && own.backgroundImage !== 'none') return null;
6155 let node = el.parentElement;
6156 while (node) {
6157 const cs = getComputedStyle(node);
6158 if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
6159 node = node.parentElement;
6160 }
6161 // The walk already passed through <body> and <html>; if they had been
6162 // opaque we would have returned. Falling through with the previous
6163 // `getComputedStyle(body).backgroundColor || …` chain is a trap: that
6164 // call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
6165 // never set its own bg, which is truthy and short-circuits the chain to
6166 // transparent-black - modern-screenshot then renders the capture on a
6167 // black canvas and the shader overlay flashes solid black during load.
6168 // The browser canvas defaults to white, so we do too.
6169 return '#ffffff';
6170 }
6171
6172 function captureChromeNodes() {
6173 const nodes = [];
6174 const add = (node) => {
6175 if (!node || node === document.body || nodes.includes(node)) return;
6176 nodes.push(node);
6177 };
6178 add(document.getElementById(PREFIX + '-root'));
6179 [
6180 PREFIX + '-highlight',
6181 PREFIX + '-tooltip',
6182 PREFIX + '-bar',
6183 PREFIX + '-picker',
6184 PREFIX + '-params-panel',
6185 PREFIX + '-insert-line',
6186 PREFIX + '-insert-placeholder',
6187 PREFIX + '-insert-create-tooltip',
6188 PREFIX + '-annot',
6189 PREFIX + '-design-host',
6190 PREFIX + '-toast',
6191 PREFIX + '-shader',
6192 ].forEach((id) => add(uiGetById(id)));
6193 return nodes;
6194 }
6195
6196 async function hideCaptureChromeForShaderProxy(fn) {
6197 const saved = captureChromeNodes().map((node) => ({
6198 node,
6199 visibility: node.style.visibility,
6200 priority: node.style.getPropertyPriority('visibility'),
6201 }));
6202 for (const { node } of saved) {
6203 node.style.setProperty('visibility', 'hidden', 'important');
6204 }
6205 await new Promise((resolve) => requestAnimationFrame(resolve));
6206 try {
6207 return await fn();
6208 } finally {
6209 for (const { node, visibility, priority } of saved) {
6210 node.style.setProperty('visibility', visibility, priority);
6211 }
6212 }
6213 }
6214
6215 function shouldUseAncestorCropShaderProxy(el) {
6216 // TODO: Enable this proxy for React/Vue/etc. adapters once their live
6217 // preview mounts are covered by the same shader regression checks.
6218 const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase();
6219 if (adapter === 'svelte' || adapter === 'sveltekit') return true;
6220 if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true;
6221 const wrapper = el?.closest?.('[data-impeccable-variants]');
6222 return wrapper?.dataset?.impeccablePreview === 'svelte-component';
6223 }
6224
6225 function paintsShaderProxySurface(node) {
6226 const s = getComputedStyle(node);
6227 return !isTransparentColor(s.backgroundColor)
6228 || (s.backgroundImage && s.backgroundImage !== 'none')
6229 || paintsBackdrop(node);
6230 }
6231
6232 function findShaderProxyCaptureRoot(el) {
6233 const doc = el.ownerDocument || document;
6234 const er = el.getBoundingClientRect();
6235 let node = el.parentElement;
6236 while (node && node !== doc.documentElement) {
6237 const nr = node.getBoundingClientRect();
6238 const containsElement =
6239 nr.width > 0 && nr.height > 0 &&
6240 nr.left <= er.left + 0.5 &&
6241 nr.top <= er.top + 0.5 &&
6242 nr.right >= er.right - 0.5 &&
6243 nr.bottom >= er.bottom - 0.5;
6244 if (containsElement && paintsShaderProxySurface(node)) return node;
6245 node = node.parentElement;
6246 }
6247 return null;
6248 }
6249
6250 // Capture the element (with current annotations baked in) and return
6251 // { blob, paper }: the PNG Blob, plus the representative backdrop tone for the
6252 // shader's halftone ground (so capture, upload, and shader all agree on what
6253 // sits behind the element). Shared between the Go flow (uploads the blob) and
6254 // the shader-resume path.
6255 async function captureElementFromRenderedAncestor(ms, el, opts) {
6256 const doc = el.ownerDocument || document;
6257 const captureRoot = findShaderProxyCaptureRoot(el);
6258 if (!captureRoot) throw new Error('No painted ancestor for Svelte shader proxy');
6259 const rootCanvas = await ms.domToCanvas(captureRoot, opts);
6260 const S = opts.scale;
6261 const er = el.getBoundingClientRect();
6262 const rr = captureRoot.getBoundingClientRect();
6263 const sx = (er.left - rr.left) * S;
6264 const sy = (er.top - rr.top) * S;
6265 const sw = er.width * S;
6266 const sh = er.height * S;
6267 if (sw <= 0 || sh <= 0) throw new Error('Selected element has no visible capture rect');
6268 const crop = doc.createElement('canvas');
6269 crop.width = Math.max(1, Math.round(sw));
6270 crop.height = Math.max(1, Math.round(sh));
6271 const cctx = crop.getContext('2d', { willReadFrequently: true });
6272 cctx.drawImage(rootCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height);
6273 const paper = dominantRgb01(cctx, crop.width, crop.height) || averageRgb01(cctx, crop.width, crop.height);
6274 const blob = await new Promise((res) => crop.toBlob(res, 'image/png'));
6275 if (!blob) throw new Error('Ancestor crop failed to produce a PNG blob');
6276 return { blob, paper };
6277 }
6278
6279 async function captureElementToBlob(el, snapshot, rect) {
6280 try { if (document.fonts?.ready) await document.fonts.ready; } catch {}
6281 const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
6282 let annotNode = null;
6283 let savedPosition = null;
6284 if (hasAnnotations) {
6285 const pos = getComputedStyle(el).position;
6286 if (pos === 'static') {
6287 savedPosition = el.style.position;
6288 el.style.position = 'relative';
6289 }
6290 annotNode = buildAnnotationsForCapture(rect, snapshot);
6291 el.appendChild(annotNode);
6292 }
6293 try {
6294 const ms = await loadModernScreenshot();
6295 const fontCssText = await collectFontCssText();
6296 const opts = {
6297 scale: Math.min(window.devicePixelRatio || 1, 2),
6298 font: fontCssText ? { cssText: fontCssText } : undefined,
6299 };
6300 if (shouldUseAncestorCropShaderProxy(el)) {
6301 try {
6302 return await hideCaptureChromeForShaderProxy(() => captureElementFromRenderedAncestor(ms, el, opts));
6303 } catch (err) {
6304 console.warn('[impeccable] Svelte ancestor crop capture failed, falling back to element capture:', err);
6305 }
6306 }
6307 const bg = resolveCanvasBackground(el);
6308 // Fast path: the element paints its own background, or an opaque ancestor
6309 // color was found. modern-screenshot bakes that color; paper matches it.
6310 if (bg !== '#ffffff') {
6311 const blob = await ms.domToBlob(el, { ...opts, ...(bg ? { backgroundColor: bg } : {}) });
6312 return { blob, paper: bg ? cssColorToRgb01(bg) : resolvePaperRgb(el) };
6313 }
6314 // Transparent up to the root. The visible backdrop may still come from an
6315 // ancestor's background-image or a covering positioned layer (e.g. a hero
6316 // art div) that the color walk can't see. Capture that ancestor and crop
6317 // to the element so the real backdrop is embedded - correct for both the
6318 // shader and the screenshot sent to the model. Fall back to white only
6319 // when nothing is actually painted behind the element.
6320 const backdrop = findBackdropAncestor(el);
6321 if (!backdrop) {
6322 const blob = await ms.domToBlob(el, { ...opts, backgroundColor: '#ffffff' });
6323 return { blob, paper: SHADER_PAPER_FALLBACK };
6324 }
6325 const ancestorCanvas = await ms.domToCanvas(backdrop, opts);
6326 const S = opts.scale;
6327 const er = el.getBoundingClientRect();
6328 const ar = backdrop.getBoundingClientRect();
6329 const sx = (er.left - ar.left) * S, sy = (er.top - ar.top) * S;
6330 const sw = er.width * S, sh = er.height * S;
6331 const crop = document.createElement('canvas');
6332 crop.width = Math.max(1, Math.round(sw));
6333 crop.height = Math.max(1, Math.round(sh));
6334 const cctx = crop.getContext('2d', { willReadFrequently: true });
6335 cctx.drawImage(ancestorCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height);
6336 // Ground = backdrop sampled around the element, falling back to the crop
6337 // mean only if the surround is fully transparent.
6338 const actx = ancestorCanvas.getContext('2d', { willReadFrequently: true });
6339 const paper = sampleSurroundingRgb(actx, sx, sy, sw, sh, ancestorCanvas.width, ancestorCanvas.height)
6340 || averageRgb01(cctx, crop.width, crop.height);
6341 const blob = await new Promise((res) => crop.toBlob(res, 'image/png'));
6342 return { blob, paper };
6343 } finally {
6344 if (annotNode) annotNode.remove();
6345 if (savedPosition !== null) el.style.position = savedPosition;
6346 }
6347 }
6348
6349 async function captureAndEmit(el, basePayload, snapshot, rect) {
6350 let screenshotPath;
6351 let blob;
6352 let paper;
6353 try {
6354 ({ blob, paper } = await captureElementToBlob(el, snapshot, rect));
6355 } catch (err) {
6356 console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
6357 }
6358 // Light up the shader overlay the moment capture is ready - no reason to
6359 // wait for the upload to complete before the user sees something alive.
6360 if (blob && state === 'GENERATING') {
6361 showShaderOverlay(el, blob, rect, paper);
6362 }
6363 // Only upload + forward the screenshot when annotations (comments/strokes)
6364 // are present. Without annotations the image is pure visual anchoring -
6365 // it biases the model toward the current rendering and works against the
6366 // three-distinct-directions brief.
6367 const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
6368 if (blob && hasAnnotations) {
6369 try {
6370 const uploadRes = await fetch(
6371 'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) +
6372 '&eventId=' + encodeURIComponent(basePayload.id),
6373 { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob },
6374 );
6375 if (uploadRes.ok) {
6376 const { path: p } = await uploadRes.json();
6377 screenshotPath = p;
6378 } else {
6379 console.warn('[impeccable] annotation upload failed:', uploadRes.status);
6380 }
6381 } catch (err) {
6382 console.warn('[impeccable] annotation upload failed:', err);
6383 }
6384 }
6385 sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload);
6386 }
6387
6388 //
6389 // Shader overlay - renders the captured screenshot as a WebGL texture and
6390 // runs an editorial "ink-wash" fragment shader over it during generation.
6391 // A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku
6392 // and leaving a soft trail. Makes the wait feel like a letterpress scan
6393 // instead of a dead spinner.
6394 //
6395
6396 const SHADER_VS = `attribute vec2 a_position;
6397 attribute vec2 a_uv;
6398 varying vec2 v_uv;
6399 void main() {
6400 v_uv = a_uv;
6401 gl_Position = vec4(a_position, 0.0, 1.0);
6402 }`;
6403
6404 const SHADER_FS = `precision highp float;
6405 uniform sampler2D u_texture;
6406 uniform float u_time;
6407 uniform vec2 u_resolution;
6408 uniform vec3 u_accent;
6409 uniform vec3 u_paper;
6410 varying vec2 v_uv;
6411
6412 // Asymmetric roller band. Product of two one-sided smoothsteps - peaks at
6413 // d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
6414 // outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
6415 // failure that reversed-edge smoothstep would give).
6416 float bandAt(float d, float leadW, float trailW) {
6417 float above = smoothstep(-leadW, 0.0, d);
6418 float below = 1.0 - smoothstep(0.0, trailW, d);
6419 return above * below;
6420 }
6421
6422 void main() {
6423 vec2 uv = v_uv;
6424 // Roller sweeps top-to-bottom with small overshoot so each cycle enters
6425 // and exits the element cleanly.
6426 float phase = fract(u_time / 3.4);
6427 float y = phase * 1.25 - 0.12;
6428 float band = bandAt(uv.y - y, 0.05, 0.32);
6429
6430 // Halftone cell grid (fixed ~10 px pitch).
6431 float cellPx = 10.0;
6432 vec2 gridUv = uv * u_resolution / cellPx;
6433 vec2 cellId = floor(gridUv);
6434 vec2 cellUv = fract(gridUv) - 0.5;
6435 vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
6436 vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
6437 // Dot size tracks how much the cell DIFFERS from the element's own ground
6438 // (u_paper), not absolute darkness. So the content - text, buttons, anything
6439 // that deviates from the background - always becomes the dots, on light AND
6440 // dark surfaces. A plain darkness curve inverts on dark elements: the dark
6441 // background fills with ink and the lighter content punches holes instead.
6442 // Capped below the cell half-width so dense content stays separated dots.
6443 float contrast = clamp(length(cellImg - u_paper) / 1.732, 0.0, 1.0);
6444 float radius = min(sqrt(contrast) * 0.6, 0.38);
6445 float dotMask = smoothstep(radius + 0.06, radius, length(cellUv));
6446 // Two-stage dissolve as the roller passes, so the element is rebuilt purely
6447 // from dot size (its own halftone) and never bleeds through as raw pixels
6448 // behind the dots:
6449 // 1. cover - the element flattens to the uniform paper ground first.
6450 // 2. dotAmt - kinpaku dots then emerge, sized by each cell's luma.
6451 // A plain mix(base, halftone, band) instead left the raw element visible
6452 // through the band's soft core/trail. The paper ground is u_paper (the
6453 // element's own bg tone) rather than a fixed white, so the dissolve reads the
6454 // same over light and dark surfaces.
6455 vec4 tex = texture2D(u_texture, uv);
6456 vec3 base = tex.rgb;
6457 float cover = smoothstep(0.0, 0.35, band);
6458 float dotAmt = dotMask * smoothstep(0.15, 0.6, band);
6459 vec3 ground = mix(base, u_paper, cover);
6460 // Carry the capture's own alpha through, so a rounded corner or any genuinely
6461 // transparent region stays transparent (the live backdrop shows through the
6462 // canvas) instead of rendering as solid black.
6463 gl_FragColor = vec4(mix(ground, u_accent, dotAmt), tex.a);
6464 }`;
6465
6466 // Kinpaku gold converted to approximate sRGB 0-1 (matches oklch(84% 0.19 80.46))
6467 const SHADER_ACCENT = [1.0, 0.78, 0.31];
6468 // Fallback ground when an element and all its ancestors are transparent -
6469 // matches the original off-white risograph paper.
6470 const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
6471 let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
6472
6473 // The element's effective background tone, used as the uniform halftone
6474 // ground so content dissolves into dots over it. Unlike resolveCanvasBackground
6475 // (which returns null when the element paints its own bg), this always returns
6476 // a usable color: the element's own background if any, else the nearest opaque
6477 // ancestor, else the paper fallback.
6478 // Rasterize any CSS color (oklch, color(), named, hex, rgb) through a 1x1
6479 // canvas and read back the sRGB pixel. String-parsing computed colors is a
6480 // trap: Chrome returns backgroundColor as oklch()/color() for oklch inputs,
6481 // which a hex/rgb regex misses - every site token would fall back to white.
6482 let colorParseCtx = null;
6483 function cssColorToRgb01(str) {
6484 if (!colorParseCtx) {
6485 colorParseCtx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
6486 }
6487 // Clear first: the ctx is cached across calls, so a semi-transparent color
6488 // would otherwise blend (source-over) with the previous call's leftover
6489 // pixel, making the result depend on call history.
6490 colorParseCtx.clearRect(0, 0, 1, 1);
6491 colorParseCtx.fillStyle = '#000'; // invalid input leaves this default
6492 colorParseCtx.fillStyle = str;
6493 colorParseCtx.fillRect(0, 0, 1, 1);
6494 const d = colorParseCtx.getImageData(0, 0, 1, 1).data;
6495 return [d[0] / 255, d[1] / 255, d[2] / 255];
6496 }
6497 function resolvePaperRgb(el) {
6498 let node = el;
6499 while (node) {
6500 const bg = getComputedStyle(node).backgroundColor;
6501 if (!isTransparentColor(bg)) return cssColorToRgb01(bg);
6502 node = node.parentElement;
6503 }
6504 return SHADER_PAPER_FALLBACK;
6505 }
6506
6507 // When an element is transparent up to the root, its visible backdrop can
6508 // still come from an ancestor's background-image or a covering positioned
6509 // layer that is a *child* of an ancestor (e.g. a hero's absolute art div) -
6510 // neither of which the ancestor background-COLOR walk can see. Return the
6511 // nearest such ancestor so we can capture it and crop, embedding the real
6512 // backdrop. Returns null when nothing is actually painted behind the element
6513 // (genuinely transparent → white is correct).
6514 function paintsBackdrop(node) {
6515 const s = getComputedStyle(node);
6516 if (s.backgroundImage && s.backgroundImage !== 'none') return true;
6517 const nr = node.getBoundingClientRect();
6518 for (const child of node.children) {
6519 const ccs = getComputedStyle(child);
6520 if (ccs.position !== 'absolute' && ccs.position !== 'fixed') continue;
6521 const paints = !isTransparentColor(ccs.backgroundColor)
6522 || (ccs.backgroundImage && ccs.backgroundImage !== 'none');
6523 if (!paints) continue;
6524 const cr = child.getBoundingClientRect();
6525 if (cr.width >= nr.width * 0.9 && cr.height >= nr.height * 0.9) return true;
6526 }
6527 return false;
6528 }
6529 function findBackdropAncestor(el) {
6530 let node = el.parentElement;
6531 while (node && node !== node.ownerDocument.documentElement) {
6532 if (paintsBackdrop(node)) return node;
6533 node = node.parentElement;
6534 }
6535 return null;
6536 }
6537
6538 // Mean sRGB (0-1) of a canvas region, used as the halftone ground when the
6539 // backdrop was captured from an ancestor rather than read from a CSS color.
6540 function averageRgb01(ctx, w, h) {
6541 const data = ctx.getImageData(0, 0, w, h).data;
6542 let r = 0, g = 0, b = 0, n = 0;
6543 // Stride a few pixels for speed; exact average is unnecessary for a ground.
6544 for (let i = 0; i < data.length; i += 16) { r += data[i]; g += data[i + 1]; b += data[i + 2]; n++; }
6545 return n ? [r / n / 255, g / n / 255, b / n / 255] : SHADER_PAPER_FALLBACK;
6546 }
6547
6548 // Pick the most common visible color cluster from a crop. A straight average
6549 // gets pulled by text and icons; the dominant bucket usually represents the
6550 // surface the shader should dissolve into.
6551 function dominantRgb01(ctx, w, h) {
6552 const data = ctx.getImageData(0, 0, w, h).data;
6553 const stride = Math.max(1, Math.floor((w * h) / 6000));
6554 const buckets = new Map();
6555 for (let p = 0; p < w * h; p += stride) {
6556 const i = p * 4;
6557 if (data[i + 3] < 16) continue;
6558 const key = (data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4);
6559 const bucket = buckets.get(key) || { count: 0, r: 0, g: 0, b: 0 };
6560 bucket.count += 1;
6561 bucket.r += data[i];
6562 bucket.g += data[i + 1];
6563 bucket.b += data[i + 2];
6564 buckets.set(key, bucket);
6565 }
6566 let best = null;
6567 for (const bucket of buckets.values()) {
6568 if (!best || bucket.count > best.count) best = bucket;
6569 }
6570 return best ? [best.r / best.count / 255, best.g / best.count / 255, best.b / best.count / 255] : null;
6571 }
6572
6573 // Average the backdrop sampled just OUTSIDE an element's rect within a larger
6574 // canvas. The ground tone for the dissolve must be the real backdrop, not the
6575 // mean of the element's own crop - averaging the crop folds in the element's
6576 // content (e.g. bright heading text), pulling the ground toward muddy gray.
6577 function sampleSurroundingRgb(ctx, sx, sy, sw, sh, W, H) {
6578 const pad = Math.max(2, Math.round(Math.min(sw, sh) * 0.12));
6579 const fx = [0.2, 0.5, 0.8].map((f) => sx + sw * f);
6580 const fy = [0.2, 0.5, 0.8].map((f) => sy + sh * f);
6581 const pts = [];
6582 for (const x of fx) { pts.push([x, sy - pad], [x, sy + sh + pad]); }
6583 for (const y of fy) { pts.push([sx - pad, y], [sx + sw + pad, y]); }
6584 let r = 0, g = 0, b = 0, n = 0;
6585 for (const [px, py] of pts) {
6586 const cx = Math.max(0, Math.min(W - 1, Math.round(px)));
6587 const cy = Math.max(0, Math.min(H - 1, Math.round(py)));
6588 const d = ctx.getImageData(cx, cy, 1, 1).data;
6589 if (d[3] === 0) continue; // outside the ancestor's paint
6590 r += d[0]; g += d[1]; b += d[2]; n++;
6591 }
6592 return n ? [r / n / 255, g / n / 255, b / n / 255] : null;
6593 }
6594
6595 function compileShader(gl, type, source) {
6596 const sh = gl.createShader(type);
6597 gl.shaderSource(sh, source);
6598 gl.compileShader(sh);
6599 if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
6600 const info = gl.getShaderInfoLog(sh);
6601 gl.deleteShader(sh);
6602 throw new Error('shader compile failed: ' + info);
6603 }
6604 return sh;
6605 }
6606
6607 function positionShaderOverlay() {
6608 if (!shaderState) return;
6609 const anchor = resolveBarAnchor();
6610 if (!anchor) return;
6611 const r = anchor.getBoundingClientRect();
6612 Object.assign(shaderState.canvas.style, {
6613 top: r.top + 'px', left: r.left + 'px',
6614 width: r.width + 'px', height: r.height + 'px',
6615 });
6616 }
6617
6618 function hideShaderOverlay() {
6619 if (!shaderState) return;
6620 if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
6621 if (shaderState.canvas) shaderState.canvas.remove();
6622 if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
6623 const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
6624 try { lose?.loseContext(); } catch {}
6625 shaderState = null;
6626 }
6627
6628 function showShaderBitmapFallback(canvas, blob) {
6629 canvas.remove();
6630 const objectUrl = URL.createObjectURL(blob);
6631 const fallback = document.createElement('div');
6632 fallback.id = PREFIX + '-shader';
6633 // Copy positioning via cssText. Object.assign across CSSStyleDeclaration
6634 // throws in modern Chromium because the source's indexed properties
6635 // (style[0], [1], ...) are read-only and the engine forbids writing
6636 // them on the destination.
6637 fallback.style.cssText = canvas.style.cssText;
6638 fallback.style.backgroundImage = 'url("' + objectUrl + '")';
6639 fallback.style.backgroundSize = '100% 100%';
6640 fallback.style.backgroundRepeat = 'no-repeat';
6641 fallback.style.outline = '2px dashed ' + C.brand;
6642 fallback.style.outlineOffset = '-2px';
6643 uiAppend(fallback);
6644 shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl };
6645 }
6646
6647 async function showShaderOverlay(el, blob, rect, paper) {
6648 hideShaderOverlay();
6649 if (!blob || !el) return;
6650 const canvas = document.createElement('canvas');
6651 canvas.id = PREFIX + '-shader';
6652 const dpr = Math.min(window.devicePixelRatio || 1, 2);
6653 const radius = getComputedStyle(el).borderRadius;
6654 canvas.width = Math.max(1, Math.floor(rect.width * dpr));
6655 canvas.height = Math.max(1, Math.floor(rect.height * dpr));
6656 Object.assign(canvas.style, {
6657 position: 'fixed',
6658 top: rect.top + 'px', left: rect.left + 'px',
6659 width: rect.width + 'px', height: rect.height + 'px',
6660 borderRadius: radius,
6661 overflow: 'hidden',
6662 pointerEvents: 'none',
6663 zIndex: Z.bar - 1,
6664 });
6665 uiAppend(canvas);
6666
6667 const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
6668 || canvas.getContext('experimental-webgl');
6669 if (!gl) {
6670 // WebGL unavailable: use the captured bitmap as a background overlay so
6671 // the user still sees something meaningful during generation.
6672 showShaderBitmapFallback(canvas, blob);
6673 return;
6674 }
6675
6676 let program, texture;
6677 try {
6678 const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS);
6679 const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS);
6680 program = gl.createProgram();
6681 gl.attachShader(program, vs);
6682 gl.attachShader(program, fs);
6683 gl.linkProgram(program);
6684 if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
6685 throw new Error('program link failed: ' + gl.getProgramInfoLog(program));
6686 }
6687 // Full-screen quad
6688 const buf = gl.createBuffer();
6689 gl.bindBuffer(gl.ARRAY_BUFFER, buf);
6690 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
6691 -1, -1, 0, 1,
6692 1, -1, 1, 1,
6693 -1, 1, 0, 0,
6694 -1, 1, 0, 0,
6695 1, -1, 1, 1,
6696 1, 1, 1, 0,
6697 ]), gl.STATIC_DRAW);
6698 const posLoc = gl.getAttribLocation(program, 'a_position');
6699 const uvLoc = gl.getAttribLocation(program, 'a_uv');
6700 gl.enableVertexAttribArray(posLoc);
6701 gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0);
6702 gl.enableVertexAttribArray(uvLoc);
6703 gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8);
6704 } catch (err) {
6705 console.warn('[impeccable] shader setup failed:', err);
6706 canvas.remove();
6707 return;
6708 }
6709
6710 // Upload the screenshot as a texture
6711 let bitmap;
6712 try {
6713 bitmap = await createImageBitmap(blob);
6714 } catch (err) {
6715 console.warn('[impeccable] shader bitmap decode failed:', err);
6716 const lose = gl.getExtension?.('WEBGL_lose_context');
6717 try { lose?.loseContext(); } catch {}
6718 showShaderBitmapFallback(canvas, blob);
6719 return;
6720 }
6721 texture = gl.createTexture();
6722 gl.bindTexture(gl.TEXTURE_2D, texture);
6723 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
6724 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
6725 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
6726 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
6727 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
6728 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap);
6729 if (bitmap.close) bitmap.close();
6730
6731 const uTime = gl.getUniformLocation(program, 'u_time');
6732 const uRes = gl.getUniformLocation(program, 'u_resolution');
6733 const uAccent = gl.getUniformLocation(program, 'u_accent');
6734 const uPaper = gl.getUniformLocation(program, 'u_paper');
6735 const uTex = gl.getUniformLocation(program, 'u_texture');
6736 const paperRgb = paper || resolvePaperRgb(el);
6737 const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
6738
6739 shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
6740 function frame() {
6741 if (!shaderState) return;
6742 const elapsed = (performance.now() - shaderState.startTime) / 1000;
6743 const t = shaderState.reduced ? 0.0 : elapsed;
6744 gl.viewport(0, 0, canvas.width, canvas.height);
6745 gl.useProgram(program);
6746 gl.activeTexture(gl.TEXTURE0);
6747 gl.bindTexture(gl.TEXTURE_2D, texture);
6748 gl.uniform1i(uTex, 0);
6749 gl.uniform1f(uTime, t);
6750 gl.uniform2f(uRes, canvas.width, canvas.height);
6751 gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]);
6752 gl.uniform3f(uPaper, paperRgb[0], paperRgb[1], paperRgb[2]);
6753 gl.drawArrays(gl.TRIANGLES, 0, 6);
6754 shaderState.rafId = requestAnimationFrame(frame);
6755 }
6756 frame();
6757 }
6758
6759 async function handleAccept() {
6760 if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
6761 if (pendingAcceptedSession || state === 'SAVING') return;
6762 if (variantSelectionPromise) {
6763 try { await variantSelectionPromise; } catch { /* failed selection falls back below */ }
6764 }
6765 if (!currentSessionId || arrivedVariants === 0) return;
6766 const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId);
6767 if (domVisibleVariant > 0) visibleVariant = domVisibleVariant;
6768 const acceptPayload = {
6769 type: 'accept',
6770 id: currentSessionId,
6771 variantId: String(visibleVariant),
6772 pageUrl: location.pathname,
6773 };
6774 const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
6775 if (Object.keys(paramsCurrentValues).length > 0) {
6776 acceptPayload.paramValues = { ...paramsCurrentValues };
6777 }
6778 // The accepted variant is already the only visible child of the wrapper
6779 // (all other variants are display:none). HMR from the source rewrite will
6780 // replace the wrapper imminently. Don't eagerly replaceChild here - React
6781 // reconciliation races with our mutation and throws NotFoundError in Next
6782 // 16 / Turbopack. Schedule a fallback that runs the manual swap only if
6783 // HMR hasn't cleaned up by then (keeps static-server flows working).
6784 const acceptedSessionId = currentSessionId;
6785 const acceptedVariant = visibleVariant;
6786 const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId
6787 || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component';
6788 const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
6789
6790 state = 'SAVING';
6791 updateBarContent('saving');
6792 pendingAcceptedSession = {
6793 id: acceptedSessionId,
6794 variant: String(acceptedVariant),
6795 isSvelteComponent: acceptedIsSvelteComponent,
6796 ...acceptedSnapshot,
6797 finalizing: false,
6798 };
6799 saveSession();
6800
6801 sendEvent(acceptPayload, { throwOnError: true })
6802 .then(() => {})
6803 .catch(() => {
6804 if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
6805 state = 'CYCLING';
6806 showOrUpdateCyclingBar();
6807 showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
6808 });
6809 }
6810
6811 function maybeCompleteAcceptedSession(msg) {
6812 const pending = pendingAcceptedSession;
6813 if (!pending || !msg?.id || msg.id !== pending.id) return false;
6814 if (currentSessionId && currentSessionId !== pending.id) {
6815 pendingAcceptedSession = null;
6816 return false;
6817 }
6818 if (pending.finalizing) return true;
6819 pending.finalizing = true;
6820 markSessionHandled();
6821 if (pending.isSvelteComponent) {
6822 commitAcceptedSvelteComponentToDom(pending.id);
6823 }
6824 state = 'CONFIRMED';
6825 updateBarContent('confirmed');
6826 scheduleAcceptCleanup(pending);
6827 return true;
6828 }
6829
6830 function scheduleAcceptCleanup(accepted) {
6831 setTimeout(function() {
6832 if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted);
6833 cleanupAcceptedSession();
6834 }, 1200);
6835 }
6836
6837 function snapshotAcceptedVariantDom(sessionId, variantId) {
6838 const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
6839 const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
6840 const root = accepted?.firstElementChild || null;
6841 return {
6842 acceptedHtml: accepted ? accepted.innerHTML : '',
6843 acceptedSelector: selectorForAcceptedRoot(root),
6844 parentElement: wrapper?.parentElement || null,
6845 parentSelector: selectorForAcceptedRoot(wrapper?.parentElement || null),
6846 nextSibling: wrapper?.nextSibling || null,
6847 };
6848 }
6849
6850 function selectorForAcceptedRoot(root) {
6851 if (!root || !root.tagName) return '';
6852 const tag = root.tagName.toLowerCase();
6853 const classes = [...(root.classList || [])].filter(Boolean);
6854 if (classes.length === 0) return tag;
6855 return tag + classes.map((cls) => '.' + cssIdent(cls)).join('');
6856 }
6857
6858 function acceptedDomAlreadyClean(pending) {
6859 if (!pending?.acceptedSelector) return false;
6860 const matches = [...document.querySelectorAll(pending.acceptedSelector)];
6861 return matches.some((el) => !el.closest('[data-impeccable-variants],[data-impeccable-variant]'));
6862 }
6863
6864 function ensureAcceptedDomClean(pending) {
6865 const sessionId = pending?.id;
6866 const variantId = pending?.variant;
6867 const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
6868 const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
6869 if (!wrapper) {
6870 restoreAcceptedDomFromSnapshot(pending);
6871 return;
6872 }
6873 if (!accepted) {
6874 wrapper.remove();
6875 restoreAcceptedDomFromSnapshot(pending);
6876 return;
6877 }
6878 const parent = wrapper.parentElement;
6879 if (!parent) return;
6880 while (accepted.firstChild) {
6881 parent.insertBefore(accepted.firstChild, wrapper);
6882 }
6883 wrapper.remove();
6884 }
6885
6886 function restoreAcceptedDomFromSnapshot(pending) {
6887 if (acceptedDomAlreadyClean(pending)) return;
6888 if (!pending?.acceptedHtml) {
6889 reloadAfterMissingAcceptedDom(pending);
6890 return;
6891 }
6892 const parent = pending.parentElement?.isConnected
6893 ? pending.parentElement
6894 : (pending.parentSelector ? document.querySelector(pending.parentSelector) : null);
6895 if (!parent) {
6896 reloadAfterMissingAcceptedDom(pending);
6897 return;
6898 }
6899 const template = document.createElement('template');
6900 template.innerHTML = pending.acceptedHtml;
6901 const anchor = pending.nextSibling?.isConnected && pending.nextSibling.parentElement === parent
6902 ? pending.nextSibling
6903 : null;
6904 parent.insertBefore(template.content, anchor);
6905 if (!acceptedDomAlreadyClean(pending)) reloadAfterMissingAcceptedDom(pending);
6906 }
6907
6908 function reloadAfterMissingAcceptedDom(pending) {
6909 if (acceptedDomAlreadyClean(pending)) return;
6910 if (pending?.id && document.querySelector('[data-impeccable-variants="' + pending.id + '"]')) return;
6911 location.reload();
6912 }
6913
6914 function cleanupAcceptedSession() {
6915 hideBar();
6916 hideHighlight();
6917 stopScrollTracking();
6918 if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
6919 stopScrollLock();
6920 clearScrollY();
6921 clearSession();
6922 resetSessionFileMeta();
6923 selectedElement = null;
6924 currentSessionId = null;
6925 selectedAction = 'impeccable';
6926 pendingAcceptedSession = null;
6927 renderEditBadge('hidden');
6928 state = 'PICKING';
6929 }
6930
6931 function commitAcceptedVariantToDom(sessionId, variantId) {
6932 const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
6933 if (!wrapper) return false;
6934 const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
6935 if (!accepted || !accepted.firstElementChild) return false;
6936 const parent = wrapper.parentElement;
6937 if (!parent) return false;
6938
6939 const style = wrapper.querySelector('style[data-impeccable-css]');
6940 if (style && !document.querySelector('style[data-impeccable-accepted-css="' + sessionId + '"]')) {
6941 const promotedStyle = style.cloneNode(true);
6942 promotedStyle.setAttribute('data-impeccable-accepted-css', sessionId);
6943 parent.insertBefore(promotedStyle, wrapper);
6944 }
6945
6946 const committed = accepted.cloneNode(true);
6947 committed.removeAttribute('hidden');
6948 committed.style.display = 'contents';
6949 parent.replaceChild(committed, wrapper);
6950 return true;
6951 }
6952
6953 function handleDiscard() {
6954 if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
6955 if (!currentSessionId) return;
6956 sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true })
6957 .then(() => {
6958 markSessionHandled();
6959 cleanup();
6960 })
6961 .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000));
6962 }
6963
6964 //
6965 // Session persistence via live-browser-session.js
6966 //
6967 // Survives page reloads, browser close/reopen, HMR, and accidental refreshes.
6968
6969 function normalizeSessionPath(value) {
6970 if (typeof value !== 'string') return null;
6971 const trimmed = value.trim();
6972 return trimmed ? trimmed.replace(/\\/g, '/') : null;
6973 }
6974
6975 function resetSessionFileMeta() {
6976 currentSourceFile = null;
6977 currentPreviewFile = null;
6978 currentPreviewMode = null;
6979 recoveryWaitingForAnchor = false;
6980 }
6981
6982 function rememberSessionFileMeta(meta = {}) {
6983 const file = normalizeSessionPath(meta.file);
6984 const sourceFile = normalizeSessionPath(meta.sourceFile);
6985 const previewFile = normalizeSessionPath(meta.previewFile);
6986 const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null);
6987
6988 if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) {
6989 currentPreviewMode = 'svelte-component';
6990 currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile);
6991 currentSourceFile = sourceFile || currentSourceFile;
6992 return;
6993 }
6994
6995 if (sourceFile || file) currentSourceFile = sourceFile || file;
6996 if (previewFile) currentPreviewFile = previewFile;
6997 if (previewMode) currentPreviewMode = previewMode;
6998 }
6999
7000 function applySavedSessionMeta(saved) {
7001 if (!saved) return;
7002 rememberSessionFileMeta(saved);
7003 if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder;
7004 if (saved.action) selectedAction = saved.action;
7005 if (saved.count) selectedCount = saved.count;
7006 if (saved.previewMode) currentPreviewMode = saved.previewMode;
7007 if (saved.paramValues && typeof saved.paramValues === 'object') {
7008 paramsCurrentValues = { ...saved.paramValues };
7009 }
7010 }
7011
7012 function normalizePagePath(value) {
7013 if (!value || typeof value !== 'string') return null;
7014 try {
7015 return new URL(value, location.origin).pathname;
7016 } catch {
7017 return value.split(/[?#]/)[0] || null;
7018 }
7019 }
7020
7021 function pageMatchesCurrent(value) {
7022 const path = normalizePagePath(value);
7023 return !path || path === location.pathname;
7024 }
7025
7026 function isTerminalSessionSummary(session) {
7027 return /^(completed|discarded|discard_requested|accept_requested)$/.test(String(session?.phase || ''));
7028 }
7029
7030 function findActiveSessionSummary(saved, activeSessions) {
7031 if (!saved?.id || !Array.isArray(activeSessions)) return null;
7032 return activeSessions.find((session) =>
7033 session?.id === saved.id
7034 && pageMatchesCurrent(session.pageUrl || saved.pageUrl)
7035 && !isTerminalSessionSummary(session)
7036 ) || null;
7037 }
7038
7039 function clampVariantIndex(value, count) {
7040 const num = Number(value);
7041 const max = Number(count);
7042 if (!Number.isFinite(num) || num < 1) return 0;
7043 if (Number.isFinite(max) && max > 0 && num > max) return 0;
7044 return Math.floor(num);
7045 }
7046
7047 function restoreSessionWithoutWrapper(reason, activeSessions) {
7048 const saved = loadSession();
7049 if (!saved?.id || isSessionHandled(saved.id)) return false;
7050 const savedState = String(saved.state || '').toUpperCase();
7051 if (savedState !== 'GENERATING' && savedState !== 'CYCLING') return false;
7052
7053 const serverSession = findActiveSessionSummary(saved, activeSessions);
7054 if (Array.isArray(activeSessions) && activeSessions.length > 0 && !serverSession) {
7055 return false;
7056 }
7057
7058 currentSessionId = saved.id;
7059 applySavedSessionMeta(serverSession);
7060 applySavedSessionMeta(saved);
7061
7062 expectedVariants = Number(saved.expected || serverSession?.expectedVariants || selectedCount || 0);
7063 arrivedVariants = Number(saved.arrived || serverSession?.arrivedVariants || 0);
7064 if (arrivedVariants <= 0 && currentPreviewFile) arrivedVariants = Number(serverSession?.expectedVariants || saved.expected || selectedCount || 0);
7065 if (expectedVariants <= 0) expectedVariants = Number(serverSession?.expectedVariants || arrivedVariants || selectedCount || 0);
7066 visibleVariant = clampVariantIndex(saved.visible, arrivedVariants || expectedVariants)
7067 || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants)
7068 || (arrivedVariants > 0 ? 1 : 0);
7069
7070 selectedElement = document.body;
7071 state = 'GENERATING';
7072 recoveryWaitingForAnchor = true;
7073 showBar('generating');
7074 startScrollTracking();
7075 if (variantObserver) variantObserver.disconnect();
7076 variantObserver = startVariantObserver(currentSessionId);
7077 saveSession();
7078 queueCheckpoint(reason || 'browser_restore_without_wrapper');
7079
7080 const restoreFile = currentPreviewMode === 'svelte-component'
7081 ? currentPreviewFile
7082 : (currentSourceFile || currentPreviewFile);
7083 if (restoreFile) {
7084 injectVariantsFromSource(restoreFile, currentSessionId);
7085 return true;
7086 }
7087
7088 showToast('Variants ready. Reveal the selected element to resume.', 15000);
7089 return true;
7090 }
7091
7092 function restoreFromActiveSessions(activeSessions, reason) {
7093 const wrapper = document.querySelector('[data-impeccable-variants]');
7094 if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false;
7095 if (svelteComponentSession?.sessionId === currentSessionId) return false;
7096 return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
7097 }
7098
7099 function saveSession() {
7100 if (!currentSessionId) return;
7101 // NOTE: scrollY is stored under a separate key (writeScrollY). Storing
7102 // it here would overwrite the Go-time value every time state changes.
7103 sessionState.saveSession({
7104 id: currentSessionId,
7105 state,
7106 action: selectedAction,
7107 count: selectedCount,
7108 expected: expectedVariants,
7109 arrived: arrivedVariants,
7110 visible: visibleVariant,
7111 sourceFile: currentSourceFile || undefined,
7112 previewFile: currentPreviewFile || undefined,
7113 previewMode: currentPreviewMode || undefined,
7114 pageUrl: location.pathname,
7115 paramValues: { ...paramsCurrentValues },
7116 insertPlaceholder: insertPlaceholderSnapshot || undefined,
7117 });
7118 }
7119
7120 function loadSession() {
7121 return sessionState.loadSession();
7122 }
7123
7124 function clearSession() {
7125 sessionState.clearSession();
7126 }
7127
7128 /** Mark session as handled (accepted/discarded). The agent will clean up
7129 * the source, but until it does the wrapper is still in the HTML. This
7130 * prevents resumeSession from picking it up again after reload. */
7131 function markSessionHandled() {
7132 if (!currentSessionId) return;
7133 sessionState.markHandled(currentSessionId);
7134 }
7135
7136 function isSessionHandled(id) {
7137 return sessionState.isHandled(id);
7138 }
7139
7140 function clearHandled() {
7141 sessionState.clearHandled();
7142 }
7143
7144 function cleanup() {
7145 const cleanupSessionId = currentSessionId;
7146 if (svelteComponentSession?.sessionId === cleanupSessionId) {
7147 teardownSvelteComponentSession(true);
7148 } else if (cleanupSessionId) {
7149 // Hide the wrapper immediately so variants disappear. DON'T structurally
7150 // mutate the DOM yet - HMR from the agent's source rewrite is on its way,
7151 // and a manual replaceChild under React causes NotFoundError when the
7152 // reconciler later tries to remove a wrapper we already removed.
7153 // Schedule a 2s fallback that does the manual swap only if HMR hasn't
7154 // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
7155 const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
7156 if (wrapper) wrapper.style.display = 'none';
7157 setTimeout(function() {
7158 if (!cleanupSessionId) return;
7159 const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
7160 if (!lateWrapper) return;
7161 const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]');
7162 if (orig) {
7163 const content = orig.firstElementChild;
7164 if (content) {
7165 lateWrapper.parentElement.replaceChild(content, lateWrapper);
7166 return;
7167 }
7168 }
7169 lateWrapper.remove();
7170 }, 2000);
7171 }
7172 hideBar();
7173 hideHighlight();
7174 stopScrollTracking();
7175 if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
7176 stopScrollLock();
7177 clearScrollY();
7178 finalizeInsertSession();
7179 clearSession();
7180 resetSessionFileMeta();
7181 selectedElement = null;
7182 currentSessionId = null;
7183 selectedAction = 'impeccable';
7184 renderEditBadge('hidden');
7185 state = 'PICKING';
7186 }
7187
7188 //
7189 // Toast
7190 //
7191
7192 function showToast(message, duration) {
7193 if (toastEl) toastEl.remove();
7194 // Stack the toast above the global bar (which sits at bottom:14px) so
7195 // the two never overlap. Read the bar's actual rect - its height varies
7196 // with hover-expanded labels - and fall back to a sensible default
7197 // when the bar isn't mounted yet.
7198 const barRect = globalBarEl?.getBoundingClientRect();
7199 const barTopFromBottom = barRect && barRect.height > 0
7200 ? Math.max(16, window.innerHeight - barRect.top + 12)
7201 : 16;
7202 toastEl = el('div', {
7203 position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
7204 transform: 'translateX(-50%) translateY(8px)',
7205 background: C.ink, color: C.white,
7206 fontFamily: FONT, fontSize: '12px',
7207 padding: '8px 16px', borderRadius: '8px',
7208 zIndex: Z.toast, opacity: '0',
7209 transition: 'opacity 0.25s ' + EASE + ', transform 0.25s ' + EASE,
7210 pointerEvents: 'none', maxWidth: '420px', textAlign: 'center',
7211 });
7212 toastEl.id = PREFIX + '-toast';
7213 toastEl.textContent = message;
7214 uiAppend(toastEl);
7215 requestAnimationFrame(() => {
7216 toastEl.style.opacity = '1';
7217 toastEl.style.transform = 'translateX(-50%) translateY(0)';
7218 });
7219 setTimeout(() => {
7220 if (toastEl) {
7221 toastEl.style.opacity = '0';
7222 toastEl.style.transform = 'translateX(-50%) translateY(8px)';
7223 setTimeout(() => { if (toastEl) { toastEl.remove(); toastEl = null; } }, 250);
7224 }
7225 }, duration);
7226 }
7227
7228 //
7229 // Init
7230 //
7231
7232 // Resume an active variant session after HMR/page reload.
7233 // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote
7234 // variants before HMR fired. Pick up where we left off.
7235 function resumeSession() {
7236 const wrapper = document.querySelector('[data-impeccable-variants]');
7237 if (!wrapper) {
7238 if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true;
7239 clearSession();
7240 clearHandled();
7241 return false;
7242 }
7243
7244 const sessionId = wrapper.dataset.impeccableVariants;
7245
7246 // Don't resume if this session was already accepted/discarded
7247 if (isSessionHandled(sessionId)) return false;
7248
7249 // Svelte component sessions can't be resumed by counting DOM children: the
7250 // wrapper holds a single mount target, not [data-impeccable-variant] nodes,
7251 // and a page reload unmounts every compiled variant. Counting children here
7252 // would strand the bar in CYCLING at 0/0. If there's no live in-memory mount
7253 // for this wrapper, it's an orphan (reload / failed mount): drop it and let
7254 // the live-server's SSE re-inject the manifest if the session is still live.
7255 if (wrapper.dataset.impeccablePreview === 'svelte-component'
7256 && svelteComponentSession?.sessionId !== sessionId) {
7257 wrapper.remove();
7258 if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true;
7259 clearSession();
7260 clearHandled();
7261 return false;
7262 }
7263
7264 if (wrapper.dataset.impeccablePreview === 'svelte-component') {
7265 if (!svelteComponentSession?.mountedVariant) {
7266 return true;
7267 }
7268 currentSessionId = sessionId;
7269 expectedVariants = Number(wrapper.dataset.impeccableVariantCount)
7270 || Number(svelteComponentSession.manifest?.count)
7271 || expectedVariants
7272 || 1;
7273 arrivedVariants = expectedVariants;
7274 const saved = loadSession();
7275 applySavedSessionMeta(saved);
7276 const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
7277 visibleVariant = svelteComponentSession.mountedVariant > 0 && svelteComponentSession.mountedVariant <= arrivedVariants
7278 ? svelteComponentSession.mountedVariant
7279 : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
7280 selectedElement = resolveSvelteComponentAnchor()
7281 || wrapper.parentElement;
7282 state = 'CYCLING';
7283 hideShaderOverlay();
7284 showBar('cycling');
7285 startScrollTracking();
7286 refreshParamsPanel();
7287 saveSession();
7288 queueCheckpoint('browser_resumed_svelte_component');
7289 return true;
7290 }
7291
7292 currentSessionId = sessionId;
7293 expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0');
7294 const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
7295 arrivedVariants = variants.length;
7296
7297 // Restore state from localStorage if available
7298 const saved = loadSession();
7299 if (saved && saved.id === sessionId) {
7300 applySavedSessionMeta(saved);
7301 visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0);
7302 if (saved.action) selectedAction = saved.action;
7303 if (saved.count) selectedCount = saved.count;
7304 } else {
7305 visibleVariant = arrivedVariants > 0 ? 1 : 0;
7306 }
7307
7308 if (saved && saved.id === sessionId && saved.insertPlaceholder) {
7309 insertPlaceholderSnapshot = saved.insertPlaceholder;
7310 }
7311
7312 const resumedState = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
7313
7314 // Find the visible variant's content element for highlight positioning.
7315 const isInsert = wrapper.dataset.impeccableMode === 'insert';
7316 const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
7317 const origEl = pickVariantContent(wrapper, 'original');
7318 state = resumedState;
7319 if (isInsert && resumedState === 'GENERATING' && arrivedVariants === 0) {
7320 selectedElement = ensureInsertPlaceholder() || findInsertAnchorInDom() || wrapper;
7321 } else {
7322 selectedElement = visEl || origEl || (isInsert ? findInsertAnchorInDom() : null) || wrapper.parentElement;
7323 }
7324
7325 // Set display state BEFORE starting observer (avoid triggering it)
7326 if (visibleVariant > 0) showVariantInDOM(currentSessionId, visibleVariant);
7327
7328 showBar(state === 'CYCLING' ? 'cycling' : 'generating');
7329 startScrollTracking();
7330 // Build the params panel for the restored visible variant. Previously
7331 // this was missed on page-reload resume: showVariantInDOM above fires
7332 // refreshParamsPanel, but state was still IDLE at that moment so it
7333 // hid. Now that state is CYCLING, re-fire.
7334 if (state === 'CYCLING') refreshParamsPanel();
7335 saveSession();
7336 queueCheckpoint('browser_resumed');
7337
7338 // Start observing for more variants AFTER initial setup
7339 if (variantObserver) variantObserver.disconnect();
7340 variantObserver = startVariantObserver(currentSessionId);
7341
7342 // Hold the target at its saved viewport top through any subsequent
7343 // HMR patches, variant inserts, or cycle swaps.
7344 startScrollLock(currentSessionId, readScrollY());
7345
7346 // If we reloaded mid-generation (Bun's HTML HMR destroys the shader
7347 // canvas), re-capture the original's content and restart the shader so
7348 // the wait doesn't go dead.
7349 if (state === 'GENERATING') {
7350 const shaderTarget = isInsert
7351 ? (ensureInsertPlaceholder() || findInsertAnchorInDom())
7352 : origEl;
7353 if (shaderTarget) {
7354 (async () => {
7355 try {
7356 const rect = shaderTarget.getBoundingClientRect();
7357 if (rect.width === 0 || rect.height === 0) return;
7358 const { blob, paper } = await captureElementToBlob(shaderTarget, null, rect);
7359 if (blob && state === 'GENERATING') {
7360 showShaderOverlay(shaderTarget, blob, rect, paper);
7361 }
7362 } catch (err) {
7363 console.warn('[impeccable] shader resume failed:', err);
7364 }
7365 })();
7366 }
7367 }
7368 return true;
7369 }
7370
7371 //
7372 // Global bar (always visible at bottom)
7373 //
7374
7375 let globalBarEl = null;
7376 let globalBarBrandEl = null;
7377 let agentPollTooltipEl = null;
7378 let agentPollingConnected = false;
7379 let agentStatusPollTimer = null;
7380 let steerFocusSuspended = false;
7381 let steerFocusPauseUntil = 0;
7382 let pagePointerGesture = null;
7383 let pagePickSkipClick = false;
7384 let steerFocusRecoverTimer = null;
7385 const STEER_PAGE_FOCUS_PAUSE_MS = 500;
7386 let detectActive = false;
7387 let detectScanSeq = 0;
7388 let activeDetectScanId = null;
7389 let pendingDetectScanId = null;
7390 const DETECT_EMPTY_MESSAGE = 'No detector issues found.';
7391 const PICK_PREFS_KEY = 'impeccable-live-pick';
7392 const INTERACTION_PREFS_KEY = 'impeccable-live-interaction';
7393 const PLACEHOLDER_DEFAULT_HEIGHT = 80;
7394 const PLACEHOLDER_MIN_HEIGHT = 48;
7395 const PLACEHOLDER_MIN_WIDTH = 120;
7396
7397 function loadInteractionPrefs() {
7398 try {
7399 const raw = localStorage.getItem(INTERACTION_PREFS_KEY);
7400 if (raw) {
7401 const prefs = JSON.parse(raw);
7402 return {
7403 pickActive: !!prefs.pickActive,
7404 insertActive: !!prefs.insertActive,
7405 };
7406 }
7407 const legacy = localStorage.getItem(PICK_PREFS_KEY);
7408 if (legacy) {
7409 const prefs = JSON.parse(legacy);
7410 return { pickActive: !!prefs.pickActive, insertActive: false };
7411 }
7412 } catch { /* ignore */ }
7413 return { pickActive: false, insertActive: false };
7414 }
7415
7416 function saveInteractionPrefs() {
7417 try {
7418 localStorage.setItem(INTERACTION_PREFS_KEY, JSON.stringify({ pickActive, insertActive }));
7419 } catch { /* ignore */ }
7420 }
7421
7422 function loadPickPref() {
7423 return loadInteractionPrefs().pickActive;
7424 }
7425
7426 function savePickPref() {
7427 saveInteractionPrefs();
7428 }
7429
7430 let pickActive = loadInteractionPrefs().pickActive;
7431 let insertActive = loadInteractionPrefs().insertActive;
7432 let configureKind = 'replace';
7433 let insertLineEl = null;
7434 let insertHoverAnchor = null;
7435 let insertHoverPosition = null;
7436 let insertHoverAxis = null;
7437 let insertAnchorElement = null;
7438 let insertAnchorPosition = null;
7439 let insertAnchorLayoutAxis = null;
7440 let insertPlaceholderSnapshot = null;
7441 let placeholderElement = null;
7442 let detectCount = 0;
7443 let detectScriptLoaded = false;
7444 let pendingDockEl = null;
7445 let pendingPillEl = null;
7446 let pendingPillSpinnerEl = null;
7447 let pendingPillLabelEl = null;
7448 let pendingPillCountEl = null;
7449 let pendingTrashBtn = null;
7450 let pendingKeepFixingBtn = null;
7451 let pendingRollbackBtn = null;
7452 let pendingDockResizeObserver = null;
7453 let pendingIntroAnimation = null;
7454 let pendingApplyInFlight = false;
7455 let firstSaveOfSession = true;
7456
7457 // Steer - collapsed pill in the global bar; expands while typing for page-level chat.
7458 let pageChatEl = null;
7459 let pageChatInput = null;
7460 let pageChatHint = null;
7461 let pageChatVoiceBtn = null;
7462 let pageChatExpanded = false;
7463 let steerLocked = false;
7464 let steerRequestId = null;
7465 let steerPendingMessage = '';
7466 let steerInputWasFocused = false;
7467 let pageChatDotsEl = null;
7468 let steerAwaitTimer = null;
7469 let voiceRecognition = null;
7470 let voiceListening = false;
7471 let voiceSuppressSubmit = false;
7472 let voiceInterimBase = '';
7473 /** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
7474 let voiceCtx = null;
7475 const PAGE_CHAT_COLLAPSED_W = '88px';
7476 const PAGE_CHAT_PROCESSING_W = '76px';
7477 const STEER_AWAIT_TIMEOUT_MS = 120000;
7478 const AGENT_STATUS_POLL_MS = 5000;
7479 const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)';
7480 const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect';
7481 const GLOBAL_BAR_SECTION_GAP = 8;
7482 const GLOBAL_BAR_INNER_GAP = 2;
7483 const GLOBAL_BAR_INNER_PAD_LEFT = 2;
7484 const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
7485 const ICON_PAGE_CHAT =
7486 '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
7487 const ICON_PAGE_VOICE =
7488 '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
7489
7490 // Theme-aware color palette for the global bar. We detect the page's
7491 // ambient background and invert - dark bar on light pages, light bar on
7492 // dark pages. This keeps the bar from fighting with the host design.
7493 function detectPageTheme() {
7494 try {
7495 // Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
7496 // 'dark' to preview the opposite palette without actually changing the
7497 // page bg. Used for screenshots and theme QA.
7498 const override = localStorage.getItem('impeccable-dev-theme');
7499 if (override === 'light' || override === 'dark') return override;
7500
7501 // Walk body → html, taking the first opaque background. The browser's
7502 // default body / html background is `rgba(0, 0, 0, 0)`, which a naive
7503 // regex would read as black and mislabel a perfectly white page as
7504 // dark. Honoring alpha avoids that - and falling through to <html>
7505 // catches the common pattern of a bg only on <html> (or only on body).
7506 function readOpaque(el) {
7507 if (!el) return null;
7508 const bg = getComputedStyle(el).backgroundColor;
7509 const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
7510 if (!m) return null;
7511 const alpha = m[4] == null ? 1 : parseFloat(m[4]);
7512 if (alpha < 0.5) return null; // transparent / nearly transparent → skip
7513 return [+m[1], +m[2], +m[3]];
7514 }
7515
7516 const rgb = readOpaque(document.body) || readOpaque(document.documentElement);
7517 // Both transparent → fall back to the browser's effective canvas color.
7518 // White is the universal default; only one in a thousand sites swaps it
7519 // via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
7520 // us catch that case.
7521 if (!rgb) {
7522 return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
7523 }
7524 const [r, g, b] = rgb;
7525 // Perceptual luminance (Rec. 709)
7526 const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
7527 return L > 0.55 ? 'light' : 'dark';
7528 } catch { return 'light'; }
7529 }
7530
7531 function barPaletteForTheme(_theme) {
7532 // Picker chrome always uses neo-kinpaku styling (homepage /live-mode demo
7533 // bars in kinpaku-kit.css), regardless of host page light/dark theme.
7534 return {
7535 surface: C.ink,
7536 surfaceDeep: C.ink,
7537 // Quiet neutral hairline (was the loud kinpaku gold border). Gold lives on
7538 // the brand mark and the active control instead.
7539 border: 'oklch(92% 0 0 / 0.13)',
7540 // Crisp graphite pill behind the active toggle (was a murky kinpaku-dim
7541 // wash); the gold text/icon carries the "selected" signal.
7542 toggleActive: 'oklch(27% 0 0)',
7543 // Neutral hairline for internal control borders / dividers (was a warm
7544 // gold rule that read as muddy champagne edges on the pill / input / count).
7545 hairline: 'oklch(92% 0 0 / 0.12)',
7546 text: 'oklch(84% 0.035 82)',
7547 textDim: 'oklch(63% 0.024 82)',
7548 accent: C.brand,
7549 accentSoft: C.brandSoft,
7550 exitHover: 'oklch(58% 0.15 35 / 0.18)',
7551 shadow: PICKER_SHADOW,
7552 chatSurface: 'oklch(22% 0.012 82)',
7553 // Verdigris patina - secondary state (see site/styles/kinpaku-tokens.css)
7554 patina: 'oklch(70% 0.12 188)',
7555 patinaPale: 'oklch(82% 0.07 188)',
7556 patinaSoft: 'oklch(70% 0.12 188 / 0.28)',
7557 };
7558 }
7559
7560 function pageChatPalette() {
7561 return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
7562 }
7563
7564 function syncPageChatChrome() {
7565 if (!pageChatEl) return;
7566 const P = pageChatPalette();
7567 pageChatEl.style.background = P.chatSurface;
7568 pageChatEl.style.borderColor = steerLocked
7569 ? P.patinaSoft
7570 : (pageChatExpanded ? P.accentSoft : P.hairline);
7571 if (pageChatHint) pageChatHint.style.color = steerLocked ? P.patinaPale : P.textDim;
7572 const chatIcon = pageChatEl?.firstElementChild;
7573 if (chatIcon) chatIcon.style.color = steerLocked ? P.patinaPale : P.textDim;
7574 if (pageChatInput) pageChatInput.style.color = P.text;
7575 if (pageChatVoiceBtn) {
7576 const listening = pageChatVoiceBtn.dataset.listening === 'true';
7577 pageChatVoiceBtn.style.color = listening || pageChatVoiceBtn.dataset.active === 'true'
7578 ? P.accent
7579 : P.textDim;
7580 }
7581 }
7582
7583 function syncPageChatVisual() {
7584 if (!pageChatInput || steerLocked) return;
7585 const hasText = pageChatInput.value.length > 0;
7586 if (hasText && !pageChatExpanded) expandPageChat({ focus: false });
7587 else if (!hasText && pageChatExpanded) collapsePageChat();
7588 }
7589
7590 function shouldFocusSteerChat() {
7591 return state !== 'CONFIGURING'
7592 && state !== 'EDITING'
7593 && !steerLocked;
7594 }
7595
7596 function pageHasHostTextSelection() {
7597 const sel = window.getSelection?.();
7598 if (!sel || sel.isCollapsed) return false;
7599 if (!(sel.toString() || '').trim()) return false;
7600 const node = sel.anchorNode;
7601 const el = node?.nodeType === 1 ? node : node?.parentElement;
7602 if (el && own(el)) return false;
7603 return true;
7604 }
7605
7606 function shouldSteerAutoFocus() {
7607 return shouldFocusSteerChat()
7608 && !steerFocusSuspended
7609 && performance.now() >= steerFocusPauseUntil;
7610 }
7611
7612 function clearSteerFocusRecoverTimer() {
7613 if (steerFocusRecoverTimer) {
7614 clearTimeout(steerFocusRecoverTimer);
7615 steerFocusRecoverTimer = null;
7616 }
7617 }
7618
7619 function scheduleSteerFocusRecover(reason) {
7620 clearSteerFocusRecoverTimer();
7621 const attempt = () => {
7622 steerFocusRecoverTimer = null;
7623 if (state === 'CONFIGURING' || steerLocked || voiceListening) return;
7624 if (pageChatEl?.contains(activeElementDeep())) return;
7625 if (pageHasHostTextSelection()) {
7626 steerFocusRecoverTimer = setTimeout(attempt, 120);
7627 return;
7628 }
7629 const pauseLeft = steerFocusPauseUntil - performance.now();
7630 if (pauseLeft > 0) {
7631 steerFocusRecoverTimer = setTimeout(attempt, pauseLeft);
7632 return;
7633 }
7634 if (!shouldFocusSteerChat()) return;
7635 syncPageChatFocus(reason);
7636 };
7637 steerFocusRecoverTimer = setTimeout(attempt, 0);
7638 }
7639
7640 function notePagePointerDown(e) {
7641 if (!shouldFocusSteerChat() || own(e.target)) return;
7642 steerFocusSuspended = true;
7643 steerFocusPauseUntil = performance.now() + STEER_PAGE_FOCUS_PAUSE_MS;
7644 pagePointerGesture = { x: e.clientX, y: e.clientY, dragged: false };
7645 if (pageChatInput && activeElementDeep() === pageChatInput) {
7646 pageChatInput.blur();
7647 }
7648 }
7649
7650 function attachSteerFocusGuard() {
7651 if (window.__IMPECCABLE_STEER_FOCUS_GUARD__) return;
7652 window.__IMPECCABLE_STEER_FOCUS_GUARD__ = true;
7653
7654 document.addEventListener('mousedown', (e) => {
7655 notePagePointerDown(e);
7656 }, true);
7657
7658 document.addEventListener('mousemove', (e) => {
7659 if (!pagePointerGesture || pagePointerGesture.dragged) return;
7660 const dx = e.clientX - pagePointerGesture.x;
7661 const dy = e.clientY - pagePointerGesture.y;
7662 if (Math.hypot(dx, dy) > 4) pagePointerGesture.dragged = true;
7663 }, true);
7664
7665 document.addEventListener('mouseup', () => {
7666 if (!shouldFocusSteerChat()) return;
7667 pagePickSkipClick = !!(pagePointerGesture?.dragged || pageHasHostTextSelection());
7668 if (pageHasHostTextSelection()) {
7669 steerFocusSuspended = true;
7670 } else {
7671 steerFocusSuspended = false;
7672 scheduleSteerFocusRecover('page-mouseup-recover');
7673 }
7674 pagePointerGesture = null;
7675 }, true);
7676
7677 document.addEventListener('selectionchange', () => {
7678 if (!shouldFocusSteerChat()) return;
7679 const wasSuspended = steerFocusSuspended;
7680 steerFocusSuspended = pageHasHostTextSelection();
7681 if (wasSuspended && !steerFocusSuspended) {
7682 scheduleSteerFocusRecover('selection-cleared');
7683 }
7684 });
7685 }
7686
7687 function steerFocusTargetLabel(el) {
7688 if (!el || el === document.body) return 'body';
7689 if (el === document.documentElement) return 'html';
7690 if (el.id) return el.tagName.toLowerCase() + '#' + el.id;
7691 return el.tagName?.toLowerCase() || String(el);
7692 }
7693
7694 function steerFocusDebugEnabled() {
7695 try { return localStorage.getItem('impeccable-steer-debug') === '1'; } catch { return false; }
7696 }
7697
7698 function steerFocusLog(reason, extra) {
7699 if (!steerFocusDebugEnabled()) return;
7700 console.log('[impeccable.steer]', reason, {
7701 state,
7702 pickActive,
7703 pageChatReady: !!pageChatInput,
7704 pageChatExpanded,
7705 active: steerFocusTargetLabel(activeElementDeep()),
7706 shouldSteer: shouldFocusSteerChat(),
7707 ...(extra || {}),
7708 });
7709 }
7710
7711 function attachSteerFocusDebug() {
7712 if (!steerFocusDebugEnabled()) return;
7713 if (window.__IMPECCABLE_STEER_FOCUS_DEBUG__) return;
7714 window.__IMPECCABLE_STEER_FOCUS_DEBUG__ = true;
7715 document.addEventListener('focusin', (e) => {
7716 if (!pageChatInput) return;
7717 steerFocusLog('focusin', { target: steerFocusTargetLabel(e.target) });
7718 }, true);
7719 }
7720
7721 function focusConfigureInput(reason) {
7722 steerFocusLog('focusConfigureInput', { reason });
7723 const inputId = configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input';
7724 const input = uiGetById(inputId);
7725 if (!input) {
7726 steerFocusLog('focusConfigureInput missing', { reason });
7727 return;
7728 }
7729 setTimeout(() => {
7730 const before = activeElementDeep();
7731 input.focus();
7732 steerFocusLog('focusConfigureInput result', {
7733 reason,
7734 before: steerFocusTargetLabel(before),
7735 after: steerFocusTargetLabel(activeElementDeep()),
7736 stuck: activeElementDeep() !== input,
7737 });
7738 }, 60);
7739 }
7740
7741 function syncPageChatFocusRing() {
7742 if (!pageChatEl || !pageChatInput) return;
7743 const focused = activeElementDeep() === pageChatInput;
7744 pageChatEl.dataset.inputFocused = focused ? 'true' : 'false';
7745 const P = pageChatPalette();
7746 pageChatEl.style.borderColor = steerLocked
7747 ? P.patinaSoft
7748 : (pageChatExpanded ? P.accentSoft : P.hairline);
7749 pageChatEl.style.boxShadow = 'none';
7750 if (pageChatHint) {
7751 pageChatHint.style.color = steerLocked
7752 ? P.patinaPale
7753 : ((!pageChatExpanded && focused) ? P.patinaPale : P.textDim);
7754 }
7755 if (!pageChatExpanded) {
7756 pageChatInput.style.width = '0';
7757 pageChatInput.style.padding = '0';
7758 pageChatInput.style.opacity = '0';
7759 pageChatInput.style.pointerEvents = focused ? 'auto' : 'none';
7760 if (pageChatHint) pageChatHint.style.visibility = '';
7761 }
7762 }
7763
7764 function focusSteerChat(reason) {
7765 steerFocusLog('focusSteerChat called', { reason });
7766 if (!pageChatInput || !shouldSteerAutoFocus()) {
7767 steerFocusLog('focusSteerChat skipped', {
7768 reason,
7769 hasInput: !!pageChatInput,
7770 shouldSteer: shouldFocusSteerChat(),
7771 suspended: steerFocusSuspended,
7772 });
7773 return;
7774 }
7775 syncPageChatVisual();
7776 pageChatInput.style.pointerEvents = 'auto';
7777 const before = activeElementDeep();
7778 try { window.focus(); } catch { /* embed may block */ }
7779 try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); }
7780 syncPageChatFocusRing();
7781 steerFocusLog('focusSteerChat result', {
7782 reason,
7783 before: steerFocusTargetLabel(before),
7784 after: steerFocusTargetLabel(activeElementDeep()),
7785 stuck: activeElementDeep() !== pageChatInput,
7786 });
7787 }
7788
7789 function syncPageChatFocus(reason) {
7790 steerFocusLog('syncPageChatFocus', { reason });
7791 if (state === 'CONFIGURING') focusConfigureInput(reason);
7792 else if (shouldSteerAutoFocus()) focusSteerChat(reason);
7793 }
7794
7795 function buildSteerProcessingDots() {
7796 const P = pageChatPalette();
7797 const wrap = el('span', {
7798 display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
7799 gap: '5px', flex: '1', minWidth: '0',
7800 padding: '0 12px 0 2px',
7801 pointerEvents: 'none',
7802 });
7803 wrap.setAttribute('aria-hidden', 'true');
7804 for (let i = 0; i < 3; i++) {
7805 wrap.appendChild(el('span', {
7806 display: 'inline-block',
7807 width: '4px', height: '4px', borderRadius: '50%',
7808 background: P.patinaPale,
7809 boxShadow: '0 0 6px ' + P.patinaSoft,
7810 animation: 'impeccable-steer-dot 1.05s ease-in-out ' + (i * 0.14) + 's infinite',
7811 }));
7812 }
7813 return wrap;
7814 }
7815
7816 function keepSteerPointerInside(e, opts = {}) {
7817 e.stopPropagation();
7818 if (opts.preventDefault !== false) e.preventDefault();
7819 }
7820
7821 function preparePageChatInputForTyping() {
7822 if (!pageChatEl || !pageChatInput) return false;
7823 pageChatExpanded = true;
7824 pageChatEl.dataset.expanded = 'true';
7825 pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
7826 pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
7827 if (pageChatHint) {
7828 pageChatHint.style.display = 'none';
7829 pageChatHint.style.opacity = '0';
7830 }
7831 pageChatInput.style.width = '';
7832 pageChatInput.style.padding = '0 6px';
7833 pageChatInput.style.opacity = steerLocked ? '0.72' : '1';
7834 pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto';
7835 return true;
7836 }
7837
7838 function focusPageChatInput(reason) {
7839 if (!preparePageChatInputForTyping() || steerLocked) return false;
7840 try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); }
7841 const focused = activeElementDeep() === pageChatInput;
7842 if (focused) steerInputWasFocused = true;
7843 syncPageChatFocusRing();
7844 return focused;
7845 }
7846
7847 function clearSteerAwaitTimer() {
7848 if (steerAwaitTimer) {
7849 clearTimeout(steerAwaitTimer);
7850 steerAwaitTimer = null;
7851 }
7852 }
7853
7854 function scheduleSteerAwaitTimeout(id) {
7855 clearSteerAwaitTimer();
7856 steerAwaitTimer = setTimeout(() => {
7857 if (!steerLocked || steerRequestId !== id) return;
7858 unlockSteerChat({
7859 error: 'Steer timed out waiting for the agent. Check that live-poll is running and replies with steer_done.',
7860 restoreMessage: steerPendingMessage,
7861 });
7862 }, STEER_AWAIT_TIMEOUT_MS);
7863 }
7864
7865 function lockSteerChat() {
7866 if (!pageChatEl || !pageChatInput) return;
7867 stopVoice({ suppressSubmit: true });
7868 steerLocked = true;
7869 pageChatEl.dataset.processing = 'true';
7870 pageChatInput.disabled = true;
7871 preparePageChatInputForTyping();
7872 if (pageChatVoiceBtn) {
7873 pageChatVoiceBtn.disabled = true;
7874 pageChatVoiceBtn.style.display = 'none';
7875 }
7876 pageChatEl.style.cursor = 'default';
7877 pageChatInput.style.pointerEvents = 'none';
7878 if (pageChatHint) {
7879 pageChatHint.style.display = 'none';
7880 pageChatHint.style.visibility = 'hidden';
7881 }
7882 pageChatEl.setAttribute('aria-busy', 'true');
7883 pageChatEl.setAttribute('aria-label', 'Processing steer request');
7884 if (!pageChatDotsEl) {
7885 pageChatDotsEl = buildSteerProcessingDots();
7886 pageChatEl.appendChild(pageChatDotsEl);
7887 }
7888 syncPageChatFocusRing();
7889 syncPageChatChrome();
7890 }
7891
7892 function unlockSteerChat(opts) {
7893 clearSteerAwaitTimer();
7894 const restoreMessage = typeof opts?.restoreMessage === 'string' ? opts.restoreMessage : '';
7895 const keepExpanded = Boolean(opts?.error && restoreMessage);
7896 steerLocked = false;
7897 const completedId = steerRequestId;
7898 steerRequestId = null;
7899 if (!pageChatEl) return;
7900 pageChatEl.dataset.processing = 'false';
7901 pageChatEl.removeAttribute('aria-busy');
7902 pageChatEl.setAttribute('aria-label', 'Steer the page');
7903 pageChatExpanded = keepExpanded;
7904 pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
7905 pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
7906 pageChatEl.style.cursor = 'pointer';
7907 if (pageChatInput) {
7908 pageChatInput.disabled = false;
7909 pageChatInput.value = keepExpanded ? restoreMessage : '';
7910 pageChatInput.style.width = keepExpanded ? '' : '0';
7911 pageChatInput.style.padding = keepExpanded ? '0 6px' : '0';
7912 pageChatInput.style.opacity = keepExpanded ? '1' : '0';
7913 pageChatInput.style.pointerEvents = 'auto';
7914 }
7915 if (pageChatVoiceBtn) {
7916 pageChatVoiceBtn.disabled = false;
7917 pageChatVoiceBtn.style.display = '';
7918 }
7919 if (pageChatHint) {
7920 pageChatHint.textContent = 'Steer';
7921 pageChatHint.style.display = keepExpanded ? 'none' : '';
7922 pageChatHint.style.visibility = keepExpanded ? 'hidden' : '';
7923 pageChatHint.style.opacity = keepExpanded ? '0' : '1';
7924 }
7925 if (pageChatDotsEl?.parentNode) {
7926 pageChatDotsEl.remove();
7927 pageChatDotsEl = null;
7928 }
7929 steerPendingMessage = keepExpanded ? restoreMessage : '';
7930 steerInputWasFocused = false;
7931 syncPageChatChrome();
7932 syncPageChatFocusRing();
7933 if (opts?.error) showToast(String(opts.error), 5000);
7934 else if (opts?.message) showToast(String(opts.message), 4000);
7935 if (completedId) {
7936 sendSteerCheckpoint(completedId, opts?.error ? 'steer_error' : 'steer_done', {
7937 message: opts?.message || opts?.error || '',
7938 file: opts?.file || '',
7939 });
7940 }
7941 if (keepExpanded) focusPageChatInput('steer-error-restore');
7942 else syncPageChatFocus('steer-unlock');
7943 }
7944
7945 function steerSpeechRecognitionCtor() {
7946 return window.SpeechRecognition || window.webkitSpeechRecognition || null;
7947 }
7948
7949 function isEmbeddedPreviewBrowser() {
7950 const ua = navigator.userAgent || '';
7951 if (/Electron/i.test(ua)) return true;
7952 if (/Cursor/i.test(ua)) return true;
7953 try {
7954 return !!(window.cursor || window.__CURSOR__ || window.__GLASS_BROWSER__);
7955 } catch { return false; }
7956 }
7957
7958 function steerVoiceUnavailableMessage() {
7959 return 'Voice input works in Chrome or Safari. Cursor\'s preview browser cannot reach speech services.';
7960 }
7961
7962 function steerVoiceErrorMessage(code) {
7963 switch (code) {
7964 case 'not-allowed':
7965 return 'Microphone access blocked';
7966 case 'audio-capture':
7967 return 'No microphone found';
7968 case 'network':
7969 return isEmbeddedPreviewBrowser()
7970 ? steerVoiceUnavailableMessage()
7971 : 'Voice input needs a network connection (browser speech uses a cloud service)';
7972 case 'service-not-allowed':
7973 return 'Voice input is not available in this browser tab';
7974 case 'language-not-supported':
7975 return 'Speech language not supported';
7976 case 'no-speech':
7977 case 'aborted':
7978 return null;
7979 default:
7980 return 'Voice input failed (' + code + ')';
7981 }
7982 }
7983
7984 function syncVoiceUi(listening) {
7985 voiceListening = !!listening;
7986 if (voiceCtx?.mode === 'steer') {
7987 if (pageChatVoiceBtn) {
7988 pageChatVoiceBtn.dataset.active = listening ? 'true' : 'false';
7989 pageChatVoiceBtn.dataset.listening = listening ? 'true' : 'false';
7990 pageChatVoiceBtn.setAttribute('aria-label', listening ? 'Stop voice input' : 'Voice input');
7991 pageChatVoiceBtn.setAttribute('aria-pressed', listening ? 'true' : 'false');
7992 }
7993 if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false';
7994 syncPageChatChrome();
7995 } else if (voiceCtx?.mode === 'configure') {
7996 const voiceBtn = uiGetById(PREFIX + '-configure-voice');
7997 if (voiceBtn) {
7998 voiceBtn.dataset.active = listening ? 'true' : 'false';
7999 voiceBtn.dataset.listening = listening ? 'true' : 'false';
8000 voiceBtn.setAttribute('aria-label', listening ? 'Stop voice input' : 'Voice input');
8001 voiceBtn.setAttribute('aria-pressed', listening ? 'true' : 'false');
8002 }
8003 syncConfigureInputChrome();
8004 }
8005 }
8006
8007 function releaseVoiceEngine(opts) {
8008 if (opts && opts.suppressSubmit) voiceSuppressSubmit = true;
8009 const rec = voiceRecognition;
8010 voiceRecognition = null;
8011 if (!rec) return;
8012 rec.onstart = null;
8013 rec.onresult = null;
8014 rec.onerror = null;
8015 rec.onend = null;
8016 try {
8017 if (opts && opts.abort) rec.abort();
8018 else rec.stop();
8019 } catch { /* already ended */ }
8020 }
8021
8022 function stopVoice(opts) {
8023 releaseVoiceEngine(opts);
8024 syncVoiceUi(false);
8025 voiceCtx = null;
8026 if (opts && opts.message) showToast(String(opts.message), opts.duration || 4000);
8027 }
8028
8029 function finishVoiceSession() {
8030 voiceRecognition = null;
8031 const ctx = voiceCtx;
8032 syncVoiceUi(false);
8033 const suppress = voiceSuppressSubmit;
8034 voiceSuppressSubmit = false;
8035 voiceCtx = null;
8036 const input = ctx?.input;
8037 const text = input?.value.trim() || '';
8038 if (suppress || !text || !ctx) return;
8039 if (ctx.mode === 'steer' && !steerLocked) ctx.submit();
8040 else if (ctx.mode === 'configure' && state === 'CONFIGURING') ctx.submit();
8041 }
8042
8043 function startVoice(ctx) {
8044 if (!ctx?.input || voiceListening) return;
8045 if (ctx.mode === 'steer' && (steerLocked || state === 'CONFIGURING')) return;
8046 if (ctx.mode === 'configure' && state !== 'CONFIGURING') return;
8047 const Ctor = steerSpeechRecognitionCtor();
8048 if (!Ctor) {
8049 showToast('Voice input needs Speech Recognition (Chrome, Safari, or Edge)', 4500);
8050 return;
8051 }
8052 if (!window.isSecureContext) {
8053 showToast('Voice input needs HTTPS or localhost', 4500);
8054 return;
8055 }
8056 if (isEmbeddedPreviewBrowser()) {
8057 showToast(steerVoiceUnavailableMessage(), 5200);
8058 return;
8059 }
8060
8061 releaseVoiceEngine({ suppressSubmit: true, abort: true });
8062 voiceSuppressSubmit = false;
8063 voiceCtx = ctx;
8064 if (ctx.beforeStart) ctx.beforeStart();
8065
8066 voiceInterimBase = ctx.input.value.trim()
8067 ? ctx.input.value.trim() + ' '
8068 : '';
8069
8070 const rec = new Ctor();
8071 rec.continuous = false;
8072 rec.interimResults = true;
8073 rec.lang = document.documentElement.lang || navigator.language || 'en-US';
8074 rec.maxAlternatives = 1;
8075
8076 rec.onstart = () => {
8077 syncVoiceUi(true);
8078 };
8079
8080 rec.onresult = (event) => {
8081 if (!voiceCtx?.input) return;
8082 let transcript = '';
8083 for (let i = 0; i < event.results.length; i++) {
8084 transcript += event.results[i][0]?.transcript || '';
8085 }
8086 voiceCtx.input.value = (voiceInterimBase + transcript).trim();
8087 if (voiceCtx.mode === 'steer') syncPageChatVisual();
8088 else syncConfigureInputChrome();
8089 };
8090
8091 rec.onerror = (event) => {
8092 const code = event.error || 'unknown';
8093 console.warn('[impeccable.voice] recognition error:', code);
8094 const message = steerVoiceErrorMessage(code);
8095 stopVoice({ suppressSubmit: true, message: message || undefined });
8096 };
8097
8098 rec.onend = () => {
8099 if (voiceRecognition !== rec) return;
8100 finishVoiceSession();
8101 };
8102
8103 voiceRecognition = rec;
8104 try {
8105 rec.start();
8106 } catch (err) {
8107 console.warn('[impeccable.voice] start failed:', err);
8108 stopVoice({
8109 suppressSubmit: true,
8110 message: err?.message?.includes('already started')
8111 ? 'Voice input already running'
8112 : 'Could not start voice input',
8113 });
8114 }
8115 }
8116
8117 function steerVoiceContext() {
8118 return {
8119 mode: 'steer',
8120 input: pageChatInput,
8121 beforeStart: () => {
8122 if (!pageChatExpanded) expandPageChat({ focus: false });
8123 },
8124 submit: submitSteerMessage,
8125 };
8126 }
8127
8128 function configureVoiceContext() {
8129 const input = uiGetById(
8130 configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input',
8131 );
8132 return {
8133 mode: 'configure',
8134 input,
8135 beforeStart: () => { input?.focus(); },
8136 submit: configureKind === 'insert' ? handleInsertCreate : handleGo,
8137 };
8138 }
8139
8140 function toggleSteerVoice() {
8141 if (voiceListening && voiceCtx?.mode === 'steer') {
8142 voiceSuppressSubmit = true;
8143 stopVoice({ suppressSubmit: true, abort: true });
8144 return;
8145 }
8146 startVoice(steerVoiceContext());
8147 }
8148
8149 function toggleConfigureVoice() {
8150 if (voiceListening && voiceCtx?.mode === 'configure') {
8151 voiceSuppressSubmit = true;
8152 stopVoice({ suppressSubmit: true, abort: true });
8153 return;
8154 }
8155 startVoice(configureVoiceContext());
8156 }
8157
8158 function submitSteerMessage() {
8159 stopVoice({ suppressSubmit: true });
8160 const text = pageChatInput?.value.trim();
8161 if (!text || steerLocked) return;
8162 const id = id8();
8163 steerRequestId = id;
8164 steerPendingMessage = text;
8165 if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true });
8166 lockSteerChat();
8167 scheduleSteerAwaitTimeout(id);
8168 sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href });
8169 sendEvent({
8170 type: 'steer',
8171 id,
8172 message: text,
8173 pageUrl: location.href,
8174 }).then((res) => {
8175 if (!res) {
8176 sendSteerCheckpoint(id, 'steer_send_failed', { message: text });
8177 unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text });
8178 }
8179 });
8180 }
8181
8182 function maybeCompleteSteer(msg) {
8183 if (!steerRequestId || msg.id !== steerRequestId) return false;
8184 if (msg.type === 'steer_done') {
8185 unlockSteerChat({ message: msg.message, file: msg.file });
8186 if (msg.file && /\.svelte(?:$|\?)/.test(String(msg.file))) {
8187 setTimeout(() => {
8188 if (!steerLocked) showToast('Steer applied. Reload if the page has not refreshed yet.', 5000);
8189 }, 4500);
8190 }
8191 return true;
8192 }
8193 if (msg.type === 'error') {
8194 unlockSteerChat({ error: msg.message || 'Steer failed', restoreMessage: steerPendingMessage });
8195 return true;
8196 }
8197 return false;
8198 }
8199
8200 function expandPageChat(opts) {
8201 const focus = !opts || opts.focus !== false;
8202 if (!pageChatEl || !pageChatInput || steerLocked) return;
8203 preparePageChatInputForTyping();
8204 syncPageChatChrome();
8205 syncPageChatFocusRing();
8206 if (focus) focusPageChatInput('expand-page-chat');
8207 }
8208
8209 function collapsePageChat(opts) {
8210 const blur = opts && opts.blur === true;
8211 if (voiceListening) return;
8212 if (!pageChatEl || !pageChatInput) return;
8213 pageChatExpanded = false;
8214 pageChatEl.dataset.expanded = 'false';
8215 pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
8216 pageChatEl.style.cursor = 'pointer';
8217 if (blur) {
8218 pageChatInput.blur();
8219 pageChatInput.style.pointerEvents = 'none';
8220 } else {
8221 pageChatInput.style.pointerEvents = 'auto';
8222 }
8223 if (pageChatHint && activeElementDeep() !== pageChatInput) {
8224 pageChatHint.style.display = '';
8225 pageChatHint.style.opacity = '1';
8226 }
8227 if (pageChatVoiceBtn) pageChatVoiceBtn.dataset.active = 'false';
8228 syncPageChatChrome();
8229 syncPageChatFocusRing();
8230 }
8231
8232 function initPageChat(parent, P) {
8233 pageChatEl = el('div', {
8234 display: 'inline-flex', alignItems: 'center',
8235 height: '28px', margin: '0 4px 0 ' + (GLOBAL_BAR_SECTION_GAP - GLOBAL_BAR_INNER_GAP) + 'px',
8236 borderRadius: '7px',
8237 background: P.chatSurface,
8238 border: '1px solid ' + P.hairline,
8239 overflow: 'hidden',
8240 cursor: 'pointer',
8241 flexShrink: '0',
8242 width: PAGE_CHAT_COLLAPSED_W,
8243 transition: 'border-color 0.15s ease',
8244 });
8245 pageChatEl.id = PREFIX + '-page-chat';
8246 pageChatEl.dataset.expanded = 'false';
8247 pageChatEl.title = 'Steer the page';
8248
8249 const chatIcon = el('span', {
8250 display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
8251 width: '28px', height: '28px', flexShrink: '0',
8252 color: P.textDim, pointerEvents: 'none',
8253 });
8254 chatIcon.innerHTML = ICON_PAGE_CHAT;
8255
8256 pageChatHint = el('span', {
8257 fontSize: '11.5px', fontWeight: '500',
8258 color: P.textDim,
8259 whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
8260 flex: '1', minWidth: '0',
8261 pointerEvents: 'none',
8262 transition: 'opacity 0.15s ease',
8263 });
8264 pageChatHint.textContent = 'Steer';
8265
8266 pageChatInput = document.createElement('input');
8267 pageChatInput.id = PREFIX + '-page-chat-input';
8268 pageChatInput.type = 'text';
8269 pageChatInput.placeholder = 'Steer the page…';
8270 pageChatInput.setAttribute('aria-label', 'Steer the page');
8271 Object.assign(pageChatInput.style, {
8272 flex: '1', minWidth: '0', width: '0',
8273 padding: '0', border: 'none', background: 'transparent',
8274 fontFamily: FONT, fontSize: '11.5px', color: P.text,
8275 outline: 'none', opacity: '0', pointerEvents: 'none',
8276 transition: 'opacity 0.15s ease',
8277 });
8278
8279 pageChatVoiceBtn = el('button', {
8280 display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
8281 padding: '0', boxSizing: 'border-box',
8282 width: '28px', height: '28px', flexShrink: '0',
8283 border: 'none', background: 'transparent',
8284 color: P.textDim, cursor: 'pointer',
8285 transition: 'color 0.12s ease, background 0.12s ease',
8286 });
8287 pageChatVoiceBtn.id = PREFIX + '-page-chat-voice';
8288 pageChatVoiceBtn.type = 'button';
8289 pageChatVoiceBtn.setAttribute('aria-label', 'Voice input');
8290 pageChatVoiceBtn.innerHTML = ICON_PAGE_VOICE;
8291
8292 pageChatEl.appendChild(chatIcon);
8293 pageChatEl.appendChild(pageChatHint);
8294 pageChatEl.appendChild(pageChatInput);
8295 pageChatEl.appendChild(pageChatVoiceBtn);
8296
8297 if (!uiGetById(PREFIX + '-page-chat-style')) {
8298 const s = document.createElement('style');
8299 s.id = PREFIX + '-page-chat-style';
8300 s.textContent =
8301 '@keyframes impeccable-steer-dot { 0%, 70%, 100% { opacity: 0.28; transform: scale(0.82); } 35% { opacity: 1; transform: scale(1); } }' +
8302 '@keyframes impeccable-steer-processing { 0%, 100% { border-color: oklch(70% 0.12 188 / 0.28); box-shadow: 0 0 0 0 oklch(70% 0.12 188 / 0); } 50% { border-color: oklch(82% 0.07 188 / 0.55); box-shadow: 0 0 14px oklch(70% 0.12 188 / 0.18); } }' +
8303 '@keyframes impeccable-voice-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }' +
8304 '#' + PREFIX + '-page-chat[data-processing="true"] { animation: impeccable-steer-processing 1.6s ease-in-out infinite; }' +
8305 '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat[data-processing="true"] { animation: none; border-color: oklch(70% 0.12 188 / 0.45); } #' + PREFIX + '-page-chat[data-processing="true"] [aria-hidden="true"] span { animation: none; opacity: 0.85; } }' +
8306 '#' + PREFIX + '-page-chat[data-voice-listening="true"] { border-color: oklch(70% 0.12 188 / 0.45); }' +
8307 '#' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: impeccable-voice-pulse 1.1s ease-in-out infinite; }' +
8308 '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' +
8309 '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' +
8310 '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }';
8311 uiAppendStyle(s);
8312 }
8313
8314 pageChatEl.addEventListener('pointerdown', keepSteerPointerInside);
8315 pageChatEl.addEventListener('mousedown', keepSteerPointerInside);
8316 pageChatEl.addEventListener('click', (e) => {
8317 keepSteerPointerInside(e);
8318 if (steerLocked) return;
8319 if (pageChatVoiceBtn.contains(e.target)) return;
8320 expandPageChat({ focus: false });
8321 focusPageChatInput('page-chat-click');
8322 });
8323
8324 pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside);
8325 pageChatVoiceBtn.addEventListener('mousedown', keepSteerPointerInside);
8326 pageChatVoiceBtn.addEventListener('click', (e) => {
8327 keepSteerPointerInside(e);
8328 if (steerLocked) return;
8329 toggleSteerVoice();
8330 });
8331
8332 pageChatInput.addEventListener('pointerdown', keepSteerPointerInside);
8333 pageChatInput.addEventListener('mousedown', keepSteerPointerInside);
8334 pageChatInput.addEventListener('click', (e) => {
8335 keepSteerPointerInside(e);
8336 if (!steerLocked) focusPageChatInput('page-chat-input-click');
8337 });
8338
8339 pageChatInput.addEventListener('input', () => {
8340 syncPageChatVisual();
8341 });
8342
8343 pageChatInput.addEventListener('focus', () => {
8344 syncPageChatFocusRing();
8345 });
8346
8347 pageChatInput.addEventListener('blur', () => {
8348 syncPageChatFocusRing();
8349 setTimeout(() => {
8350 if (state === 'CONFIGURING' || steerLocked || voiceListening) return;
8351 if (pageChatEl?.contains(activeElementDeep())) return;
8352 if (!pageChatInput.value.trim()) collapsePageChat();
8353 scheduleSteerFocusRecover('steer-blur-recover');
8354 }, 120);
8355 });
8356
8357 pageChatInput.addEventListener('keydown', (e) => {
8358 if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !pageChatInput.value) return;
8359 e.stopPropagation();
8360 if (e.key === 'Escape') {
8361 e.preventDefault();
8362 if (pageChatInput.value) {
8363 pageChatInput.value = '';
8364 syncPageChatVisual();
8365 } else {
8366 collapsePageChat();
8367 }
8368 return;
8369 }
8370 if (e.key === 'Enter') {
8371 e.preventDefault();
8372 submitSteerMessage();
8373 }
8374 });
8375
8376 parent.appendChild(pageChatEl);
8377 steerFocusLog('page-chat-mounted', {});
8378 }
8379
8380 // Impeccable mark - same paths as site/components/Header.astro + favicon.svg.
8381 function brandMarkSvg(color = C.brand, size = 18) {
8382 return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${color}" aria-hidden="true">
8383 <path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
8384 <path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/>
8385 </svg>`;
8386 }
8387
8388 function syncAgentPollingUi(connected) {
8389 agentPollingConnected = !!connected;
8390 if (!globalBarBrandEl) return;
8391 const P = barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
8392 globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
8393 globalBarBrandEl.setAttribute('aria-label', connected
8394 ? 'Impeccable live mode'
8395 : 'Impeccable live mode - agent not polling');
8396 globalBarBrandEl.removeAttribute('title');
8397 globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
8398 const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
8399 if (mark) {
8400 mark.innerHTML = brandMarkSvg(connected ? P.accent : AGENT_DISCONNECTED_MARK, 18);
8401 mark.style.opacity = '1';
8402 }
8403 const dot = globalBarBrandEl.querySelector('[data-agent-dot]');
8404 if (dot) dot.style.display = connected ? 'none' : 'block';
8405 if (connected) hideAgentPollTooltip();
8406 }
8407
8408 function ensureAgentPollTooltip() {
8409 if (agentPollTooltipEl) return agentPollTooltipEl;
8410 const P = barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
8411 agentPollTooltipEl = el('div', {
8412 position: 'fixed',
8413 display: 'none',
8414 opacity: '0',
8415 zIndex: String(Z.bar + 6),
8416 pointerEvents: 'none',
8417 maxWidth: '220px',
8418 padding: '6px 9px',
8419 borderRadius: '7px',
8420 background: P.chatSurface,
8421 border: '1px solid ' + P.hairline,
8422 boxShadow: P.shadow,
8423 color: P.text,
8424 fontFamily: FONT,
8425 fontSize: '11px',
8426 fontWeight: '500',
8427 lineHeight: '1.35',
8428 letterSpacing: '0.01em',
8429 whiteSpace: 'normal',
8430 });
8431 agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip';
8432 agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP;
8433 uiAppend(agentPollTooltipEl);
8434 return agentPollTooltipEl;
8435 }
8436
8437 function showAgentPollTooltip(anchor) {
8438 if (agentPollingConnected || !anchor) return;
8439 const tip = ensureAgentPollTooltip();
8440 tip.style.transition = 'none';
8441 tip.style.display = 'block';
8442 tip.style.opacity = '1';
8443 const r = anchor.getBoundingClientRect();
8444 const tipW = tip.offsetWidth;
8445 const tipH = tip.offsetHeight;
8446 const left = Math.max(8, Math.min(window.innerWidth - tipW - 8, r.left + r.width / 2 - tipW / 2));
8447 const top = Math.max(8, r.top - tipH - 8);
8448 tip.style.left = left + 'px';
8449 tip.style.top = top + 'px';
8450 }
8451
8452 function hideAgentPollTooltip() {
8453 if (!agentPollTooltipEl) return;
8454 agentPollTooltipEl.style.display = 'none';
8455 agentPollTooltipEl.style.opacity = '0';
8456 }
8457
8458 function stopAgentStatusPoll() {
8459 if (agentStatusPollTimer) {
8460 clearInterval(agentStatusPollTimer);
8461 agentStatusPollTimer = null;
8462 }
8463 }
8464
8465 function fetchAgentPollingStatus() {
8466 fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
8467 .then((res) => (res.ok ? res.json() : null))
8468 .then((data) => {
8469 if (data && typeof data.agentPolling === 'boolean') syncAgentPollingUi(data.agentPolling);
8470 })
8471 .catch(() => { /* server loss handled elsewhere */ });
8472 }
8473
8474 function startAgentStatusPoll() {
8475 stopAgentStatusPoll();
8476 fetchAgentPollingStatus();
8477 agentStatusPollTimer = setInterval(fetchAgentPollingStatus, AGENT_STATUS_POLL_MS);
8478 }
8479
8480 function initGlobalBar() {
8481 const theme = detectPageTheme();
8482 const P = barPaletteForTheme(theme);
8483
8484 // Custom focus-visible for bar buttons. Browser default is a heavy
8485 // blue ring that looks jarring on the dark capsule. Replace with a
8486 // soft accent-tinted inner ring that respects the bar's palette.
8487 if (!uiGetById(PREFIX + '-bar-focus-style')) {
8488 const s = document.createElement('style');
8489 s.id = PREFIX + '-bar-focus-style';
8490 s.textContent =
8491 '#' + PREFIX + '-global-bar button:focus { outline: none; }' +
8492 '#' + PREFIX + '-global-bar button:focus-visible {' +
8493 ' outline: none;' +
8494 ' box-shadow: 0 0 0 2px ' + P.accentSoft + ', 0 0 0 3px ' + P.accent + ';' +
8495 '}' +
8496 '@keyframes impeccable-agent-dot { 0%, 100% { opacity: 0.45; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1); } }' +
8497 '#' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: impeccable-agent-dot 1.4s ease-in-out infinite; }' +
8498 '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: none; opacity: 0.9; } }';
8499 uiAppendStyle(s);
8500 }
8501
8502 globalBarEl = el('div', {
8503 position: 'fixed', bottom: '14px', left: '50%',
8504 transform: 'translateX(-50%) translateY(20px)',
8505 zIndex: Z.bar + 5,
8506 display: 'flex', alignItems: 'stretch',
8507 gap: '0',
8508 background: P.surface,
8509 border: '1px solid ' + P.border,
8510 borderRadius: '8px',
8511 boxShadow: P.shadow,
8512 fontFamily: FONT, fontSize: '12px', lineHeight: '1',
8513 opacity: '0',
8514 overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
8515 transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
8516 });
8517 globalBarEl.id = PREFIX + '-global-bar';
8518 globalBarEl.dataset.theme = theme;
8519
8520 // Brand mark - kinpaku Impeccable icon (site header / favicon paths).
8521 const brand = el('span', {
8522 display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
8523 alignSelf: 'stretch', position: 'relative',
8524 padding: '0 ' + (GLOBAL_BAR_SECTION_GAP - GLOBAL_BAR_INNER_PAD_LEFT) + 'px 0 14px',
8525 background: 'transparent',
8526 color: P.accent,
8527 flexShrink: '0',
8528 });
8529 brand.id = PREFIX + '-global-bar-brand';
8530 brand.dataset.agentConnected = 'false';
8531 brand.setAttribute('role', 'img');
8532 brand.setAttribute('aria-label', 'Impeccable live mode - agent not polling');
8533
8534 const brandMark = el('span', {
8535 display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
8536 position: 'relative',
8537 });
8538 brandMark.dataset.brandMark = 'true';
8539 brandMark.innerHTML = brandMarkSvg(P.accent, 18);
8540
8541 const agentDot = el('span', {
8542 position: 'absolute', right: '-1px', bottom: '7px',
8543 width: '6px', height: '6px', borderRadius: '50%',
8544 background: 'oklch(78% 0.14 75)',
8545 boxShadow: '0 0 0 2px ' + P.surface,
8546 display: 'none', pointerEvents: 'none',
8547 });
8548 agentDot.dataset.agentDot = 'true';
8549 agentDot.setAttribute('aria-hidden', 'true');
8550
8551 brandMark.appendChild(agentDot);
8552 brand.appendChild(brandMark);
8553 brand.addEventListener('mouseenter', () => showAgentPollTooltip(brand));
8554 brand.addEventListener('mouseleave', hideAgentPollTooltip);
8555 globalBarBrandEl = brand;
8556 globalBarEl.appendChild(brand);
8557 syncAgentPollingUi(false);
8558
8559 // Inner wrapper: holds the toggles with normal bar padding.
8560 const inner = el('div', {
8561 display: 'flex', alignItems: 'center',
8562 padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
8563 });
8564 inner.id = PREFIX + '-global-bar-inner';
8565 globalBarEl.appendChild(inner);
8566
8567 // Button factory: icon-only at rest, label slides in on hover/active.
8568 function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
8569 const b = el('button', {
8570 position: 'relative',
8571 display: 'inline-flex', alignItems: 'center',
8572 padding: '6px 8px', borderRadius: '7px',
8573 border: 'none', background: 'transparent',
8574 color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
8575 cursor: 'pointer',
8576 transition: 'background 0.15s ease, color 0.15s ease',
8577 whiteSpace: 'nowrap', overflow: 'hidden',
8578 });
8579 b.id = id;
8580 b.title = ariaLabel || label || '';
8581 b.setAttribute('aria-label', ariaLabel || label || '');
8582 b.innerHTML = svg + (label
8583 ? `<span class="icon-btn-label" style="display:inline-block;max-width:0;opacity:0;margin-left:0;overflow:hidden;font-family:${labelFont || FONT};transform:translateX(-4px);transition:opacity 0.2s ease, transform 0.25s ${EASE};">${label}</span>`
8584 : '');
8585 const labelEl = b.querySelector('.icon-btn-label');
8586 const expand = () => {
8587 if (!labelEl) return;
8588 labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
8589 };
8590 const collapse = () => {
8591 if (!labelEl || b.dataset.active === 'true') return;
8592 labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
8593 };
8594 // Per-button hover only changes color (no layout). The label expand/
8595 // collapse is driven by the bar-level mouseenter/mouseleave so moving
8596 // the mouse between adjacent buttons doesn't trigger per-button width
8597 // thrashing - the whole bar grows once and shrinks once.
8598 b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
8599 b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
8600 b.addEventListener('click', onClick);
8601 b._expandLabel = expand;
8602 b._collapseLabel = collapse;
8603 return b;
8604 }
8605
8606 // Pick toggle - restored from localStorage; both pick and insert may be off.
8607 const pickBtn = makeIconBtn({
8608 id: PREFIX + '-pick-toggle',
8609 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>',
8610 label: 'Pick',
8611 ariaLabel: 'Pick element',
8612 onClick: () => togglePick(),
8613 });
8614 inner.appendChild(pickBtn);
8615
8616 const insertBtn = makeIconBtn({
8617 id: PREFIX + '-insert-toggle',
8618 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="M12 5v14"/><path d="M5 12h14"/></svg>',
8619 label: 'Insert',
8620 ariaLabel: 'Insert new element',
8621 onClick: () => toggleInsert(),
8622 });
8623 inner.appendChild(insertBtn);
8624
8625 // Detect toggle
8626 const detectBtn = makeIconBtn({
8627 id: PREFIX + '-detect-toggle',
8628 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>',
8629 label: 'Detect',
8630 ariaLabel: 'Detect anti-patterns',
8631 onClick: () => toggleDetect(),
8632 });
8633 const detectBadge = el('span', {
8634 fontSize: '10px', fontWeight: '600',
8635 padding: '0px 5px', borderRadius: '7px', lineHeight: '16px',
8636 background: P.accent, color: C.ink,
8637 display: 'none', fontFamily: MONO, marginLeft: '4px',
8638 });
8639 detectBadge.id = PREFIX + '-detect-badge';
8640 detectBtn.appendChild(detectBadge);
8641 inner.appendChild(detectBtn);
8642
8643 // DESIGN.md panel toggle - quartet of color squares as the mark.
8644 const designBtn = makeIconBtn({
8645 id: PREFIX + '-design-toggle',
8646 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 oklch(58% 0.065 82 / 0.55);flex-shrink:0">
8647 <span style="background:oklch(84% 0.19 80.46)"></span>
8648 <span style="background:oklch(70% 0.12 188)"></span>
8649 <span style="background:oklch(84% 0.035 82)"></span>
8650 <span style="background:oklch(34% 0.014 82)"></span>
8651 </span>`,
8652 label: 'DESIGN.md',
8653 ariaLabel: 'Toggle DESIGN.md panel',
8654 labelFont: MONO,
8655 onClick: () => toggleDesignPanel(),
8656 });
8657 inner.appendChild(designBtn);
8658
8659 initPageChat(inner, P);
8660
8661 // Pending manual edits live outside the bar so applying staged copy edits
8662 // reads as a distinct next step instead of another chrome toggle.
8663 pendingDockEl = el('div', {
8664 position: 'fixed',
8665 left: '0',
8666 bottom: '0',
8667 transform: 'translate(-100%, 50%)',
8668 zIndex: String(Z.bar + 6),
8669 display: 'none',
8670 alignItems: 'center',
8671 gap: '6px',
8672 fontFamily: FONT,
8673 pointerEvents: 'auto',
8674 });
8675 pendingDockEl.id = PREFIX + '-pending-dock';
8676
8677 pendingPillEl = el('button', {
8678 display: 'none',
8679 alignItems: 'center',
8680 gap: '8px',
8681 fontFamily: FONT,
8682 fontSize: '12px',
8683 fontWeight: '600',
8684 letterSpacing: '0',
8685 color: C.ink,
8686 background: P.accent,
8687 padding: '7px 12px 7px 14px',
8688 border: 'none',
8689 borderRadius: '999px',
8690 whiteSpace: 'nowrap',
8691 cursor: 'pointer',
8692 boxShadow: '0 4px 16px oklch(0% 0 0 / 0.16), 0 1px 3px oklch(0% 0 0 / 0.1)',
8693 transition: 'filter 0.12s ease, transform 0.1s ease, box-shadow 0.18s ease',
8694 });
8695 pendingPillEl.title = 'Apply copy edits to source';
8696 pendingPillSpinnerEl = el('span', {
8697 display: 'none',
8698 width: '12px',
8699 height: '12px',
8700 borderRadius: '50%',
8701 border: '2px solid currentColor',
8702 borderTopColor: 'transparent',
8703 color: C.ink,
8704 opacity: '0.9',
8705 animation: 'impeccable-spin 0.6s linear infinite',
8706 flex: '0 0 auto',
8707 boxSizing: 'border-box',
8708 });
8709 pendingPillLabelEl = el('span', { lineHeight: '1', whiteSpace: 'nowrap' });
8710 pendingPillLabelEl.textContent = 'Apply copy edits';
8711 pendingPillCountEl = el('span', {
8712 display: 'inline-flex',
8713 alignItems: 'center',
8714 justifyContent: 'center',
8715 minWidth: '17px',
8716 height: '17px',
8717 padding: '0 5px',
8718 borderRadius: '999px',
8719 background: 'oklch(4% 0.004 95 / 0.18)',
8720 color: C.ink,
8721 fontFamily: MONO,
8722 fontSize: '10px',
8723 fontWeight: '700',
8724 lineHeight: '1',
8725 });
8726 ensureSpinKeyframes();
8727 pendingPillEl.appendChild(pendingPillSpinnerEl);
8728 pendingPillEl.appendChild(pendingPillLabelEl);
8729 pendingPillEl.appendChild(pendingPillCountEl);
8730 pendingPillEl.addEventListener('mouseenter', () => {
8731 if (pendingApplyInFlight) return;
8732 pendingPillEl.style.filter = 'brightness(1.1)';
8733 pendingPillEl.style.boxShadow = '0 7px 22px oklch(0% 0 0 / 0.18), 0 2px 5px oklch(0% 0 0 / 0.12)';
8734 });
8735 pendingPillEl.addEventListener('mouseleave', () => {
8736 if (pendingApplyInFlight) return;
8737 pendingPillEl.style.filter = 'none';
8738 pendingPillEl.style.transform = 'scale(1)';
8739 pendingPillEl.style.boxShadow = '0 4px 16px oklch(0% 0 0 / 0.16), 0 1px 3px oklch(0% 0 0 / 0.1)';
8740 });
8741 pendingPillEl.addEventListener('mousedown', () => { if (!pendingApplyInFlight) pendingPillEl.style.transform = 'scale(0.97)'; });
8742 pendingPillEl.addEventListener('mouseup', () => { pendingPillEl.style.transform = 'scale(1)'; });
8743 pendingPillEl.addEventListener('click', onPendingPillClick);
8744
8745 pendingTrashBtn = el('button', {
8746 position: 'relative',
8747 display: 'none',
8748 alignItems: 'center',
8749 justifyContent: 'center',
8750 padding: '0', boxSizing: 'border-box',
8751 width: '30px', height: '30px', borderRadius: '999px',
8752 border: '1px solid ' + P.hairline,
8753 background: P.chatSurface,
8754 color: P.textDim,
8755 overflow: 'visible',
8756 boxShadow: '0 4px 16px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)',
8757 cursor: 'pointer',
8758 transition: 'color 0.12s ease, background 0.12s ease, box-shadow 0.18s ease',
8759 });
8760 pendingTrashBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="flex:0 0 auto"><path d="M3 4h8"/><path d="M5 4V3a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1"/><path d="M4 4l.5 7a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1L10 4"/></svg>';
8761 const pendingTrashTooltipEl = el('span', {
8762 position: 'absolute',
8763 bottom: 'calc(100% + 8px)',
8764 left: '50%',
8765 transform: 'translateX(-50%) translateY(4px)',
8766 opacity: '0',
8767 pointerEvents: 'none',
8768 padding: '8px 16px',
8769 borderRadius: '8px',
8770 background: C.ink,
8771 color: C.white,
8772 fontFamily: FONT,
8773 fontSize: '12px',
8774 fontWeight: '400',
8775 lineHeight: '1',
8776 whiteSpace: 'nowrap',
8777 textAlign: 'center',
8778 transition: 'opacity 0.16s ease, transform 0.18s ' + EASE,
8779 });
8780 pendingTrashTooltipEl.textContent = 'Discard copy edits';
8781 pendingTrashTooltipEl.setAttribute('role', 'tooltip');
8782 pendingTrashBtn.appendChild(pendingTrashTooltipEl);
8783 pendingTrashBtn.setAttribute('aria-label', 'Discard copy edits on this page');
8784 const showTrashTooltip = () => {
8785 pendingTrashBtn.style.color = P.accent;
8786 pendingTrashBtn.style.boxShadow = '0 7px 22px oklch(0% 0 0 / 0.16), 0 2px 5px oklch(0% 0 0 / 0.1)';
8787 pendingTrashTooltipEl.style.opacity = '1';
8788 pendingTrashTooltipEl.style.transform = 'translateX(-50%) translateY(0)';
8789 };
8790 const hideTrashTooltip = () => {
8791 pendingTrashBtn.style.color = P.textDim;
8792 pendingTrashBtn.style.background = P.chatSurface;
8793 pendingTrashBtn.style.boxShadow = '0 4px 16px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)';
8794 pendingTrashTooltipEl.style.opacity = '0';
8795 pendingTrashTooltipEl.style.transform = 'translateX(-50%) translateY(4px)';
8796 };
8797 pendingTrashBtn.addEventListener('mouseenter', showTrashTooltip);
8798 pendingTrashBtn.addEventListener('mouseleave', hideTrashTooltip);
8799 pendingTrashBtn.addEventListener('focus', showTrashTooltip);
8800 pendingTrashBtn.addEventListener('blur', hideTrashTooltip);
8801 pendingTrashBtn.addEventListener('click', onPendingTrashClick);
8802
8803 const makePendingDecisionBtn = (label, accent) => {
8804 const btn = el('button', {
8805 display: 'none',
8806 alignItems: 'center',
8807 justifyContent: 'center',
8808 height: '30px',
8809 padding: '0 12px',
8810 borderRadius: '999px',
8811 border: '1px solid ' + (accent ? P.accent : P.hairline),
8812 background: accent ? P.accent : P.chatSurface,
8813 color: accent ? C.ink : P.textDim,
8814 fontFamily: FONT,
8815 fontSize: '12px',
8816 fontWeight: '600',
8817 letterSpacing: '0',
8818 cursor: 'pointer',
8819 whiteSpace: 'nowrap',
8820 boxShadow: '0 4px 16px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)',
8821 });
8822 btn.textContent = label;
8823 return btn;
8824 };
8825 pendingKeepFixingBtn = makePendingDecisionBtn('Keep fixing', true);
8826 pendingKeepFixingBtn.setAttribute('aria-label', 'Ask the agent to keep fixing Apply errors');
8827 pendingKeepFixingBtn.addEventListener('click', onPendingKeepFixingClick);
8828 pendingRollbackBtn = makePendingDecisionBtn('Rollback', false);
8829 pendingRollbackBtn.setAttribute('aria-label', 'Rollback source and keep copy edits staged');
8830 pendingRollbackBtn.addEventListener('click', onPendingRollbackClick);
8831
8832 pendingDockEl.appendChild(pendingPillEl);
8833 pendingDockEl.appendChild(pendingTrashBtn);
8834 pendingDockEl.appendChild(pendingKeepFixingBtn);
8835 pendingDockEl.appendChild(pendingRollbackBtn);
8836
8837 // Thin divider before the exit button
8838 const divider = el('span', {
8839 width: '1px', height: '18px',
8840 background: P.hairline,
8841 margin: '0 4px 0 2px',
8842 });
8843 inner.appendChild(divider);
8844
8845 // Exit × on the right - intentionally subtle (textDim at rest, text on
8846 // hover) so it sits behind the active toggles in visual hierarchy.
8847 //
8848 // Explicit padding + box-sizing here is load-bearing: a host page like
8849 // `button { padding: 0.5rem 1rem; }` (very common in resets) would
8850 // otherwise inflate this 24x24 button into 56x40 and push the SVG out
8851 // of the visible bar - the X stays invisible even though the styles in
8852 // DevTools look fine. Every other chrome button sets padding inline;
8853 // this one needed it too.
8854 const exitBtn = el('button', {
8855 display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
8856 padding: '0', boxSizing: 'border-box',
8857 width: '24px', height: '24px', borderRadius: '6px',
8858 border: 'none', background: 'transparent',
8859 color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
8860 cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
8861 });
8862 exitBtn.id = PREFIX + '-exit';
8863 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>';
8864 exitBtn.title = 'Exit live mode';
8865 exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = 'oklch(58% 0.15 35)'; exitBtn.style.background = P.exitHover; });
8866 exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
8867 exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
8868 inner.appendChild(exitBtn);
8869
8870 // Bar-level hover: expand every toggle's label at once; collapse on leave.
8871 // Buttons with dataset.active="true" ignore collapse (their label stays).
8872 const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
8873 globalBarEl.addEventListener('mouseenter', () => {
8874 toggles.forEach((t) => t._expandLabel && t._expandLabel());
8875 schedulePendingDockPosition();
8876 setTimeout(schedulePendingDockPosition, 260);
8877 });
8878 globalBarEl.addEventListener('mouseleave', () => {
8879 toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
8880 schedulePendingDockPosition();
8881 setTimeout(schedulePendingDockPosition, 260);
8882 });
8883 globalBarEl.addEventListener('pointerdown', () => {
8884 try { window.focus(); } catch { /* in-app preview may block */ }
8885 }, true);
8886
8887 uiAppend(pendingDockEl);
8888 uiAppend(globalBarEl);
8889 defangOutsideHandlers(pendingDockEl);
8890 defangOutsideHandlers(globalBarEl);
8891
8892 if (window.ResizeObserver) {
8893 pendingDockResizeObserver = new ResizeObserver(schedulePendingDockPosition);
8894 pendingDockResizeObserver.observe(globalBarEl);
8895 }
8896 window.addEventListener('resize', positionPendingDock);
8897
8898 requestAnimationFrame(() => {
8899 globalBarEl.style.opacity = '1';
8900 globalBarEl.style.transform = 'translateX(-50%) translateY(0)';
8901 syncPageChatFocus('global-bar-visible');
8902 });
8903
8904 // Listen for detection results AND ready signal
8905 window.addEventListener('message', onDetectMessage);
8906 updateGlobalBarState();
8907 }
8908
8909 function updateGlobalBarState() {
8910 const detectToggle = uiGetById(PREFIX + '-detect-toggle');
8911 const detectBadge = uiGetById(PREFIX + '-detect-badge');
8912 const pickToggle = uiGetById(PREFIX + '-pick-toggle');
8913 const insertToggle = uiGetById(PREFIX + '-insert-toggle');
8914 const designToggle = uiGetById(PREFIX + '-design-toggle');
8915 const theme = globalBarEl?.dataset.theme || 'light';
8916 const P = barPaletteForTheme(theme);
8917
8918 // Sync one toggle's active state, colors, and slide-label visibility.
8919 function sync(btn, active) {
8920 if (!btn) return;
8921 btn.style.background = active ? P.toggleActive : 'transparent';
8922 btn.style.color = active ? P.accent : P.textDim;
8923 btn.dataset.active = active ? 'true' : 'false';
8924 if (active && btn._expandLabel) btn._expandLabel();
8925 else if (!active && btn._collapseLabel) btn._collapseLabel();
8926 }
8927 sync(pickToggle, pickActive);
8928 sync(insertToggle, insertActive);
8929 sync(detectToggle, detectActive);
8930 sync(designToggle, designState.open);
8931
8932 const controlsLocked = pendingApplyInFlight === true;
8933 [pickToggle, insertToggle, detectToggle, designToggle].forEach((btn) => {
8934 if (!btn) return;
8935 btn.disabled = controlsLocked;
8936 btn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer';
8937 btn.style.opacity = controlsLocked ? '0.55' : '1';
8938 });
8939
8940 // If the bar is currently under the cursor, keep all labels expanded -
8941 // otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
8942 // would collapse its label while the user's mouse is still on the bar.
8943 if (globalBarEl && globalBarEl.matches(':hover')) {
8944 [pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
8945 }
8946
8947 if (detectBadge) {
8948 detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
8949 detectBadge.textContent = detectCount;
8950 }
8951
8952 // When pick/insert is active, make detect overlays click-through
8953 document.querySelectorAll('.impeccable-overlay').forEach(o => {
8954 o.style.pointerEvents = (pickActive || insertActive) ? 'none' : '';
8955 });
8956 syncPageInteractionCursor();
8957 }
8958
8959 let detectReady = false; // true once detect script posts 'impeccable-ready'
8960 let detectPendingScan = false; // scan requested before script was ready
8961
8962 function requestDetectScan() {
8963 const scanId = String(++detectScanSeq);
8964 activeDetectScanId = scanId;
8965 pendingDetectScanId = scanId;
8966 window.postMessage({
8967 source: 'impeccable-command',
8968 action: 'scan',
8969 config: { scanId },
8970 }, '*');
8971 }
8972
8973 function toggleDetect() {
8974 if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
8975 detectActive = !detectActive;
8976 updateGlobalBarState();
8977
8978 if (detectActive) {
8979 if (!detectScriptLoaded) {
8980 detectPendingScan = true;
8981 loadDetectScript();
8982 } else if (detectReady) {
8983 requestDetectScan();
8984 } else {
8985 detectPendingScan = true;
8986 }
8987 } else {
8988 window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
8989 activeDetectScanId = null;
8990 pendingDetectScanId = null;
8991 detectCount = 0;
8992 updateGlobalBarState();
8993 }
8994 }
8995
8996 function togglePick() {
8997 if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
8998 pickActive = !pickActive;
8999 if (pickActive) {
9000 insertActive = false;
9001 clearInsertPicking();
9002 }
9003 saveInteractionPrefs();
9004 updateGlobalBarState();
9005
9006 if (!pickActive) {
9007 if (configureKind === 'insert' && state === 'CONFIGURING') {
9008 cancelInsertConfigure();
9009 return;
9010 }
9011 hideHighlight();
9012 hideBar();
9013 hideActionPicker();
9014 selectedElement = null;
9015 configureKind = 'replace';
9016 if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE';
9017 } else {
9018 if (state === 'IDLE') state = 'PICKING';
9019 }
9020 syncPageChatFocus('toggle-pick');
9021 }
9022
9023 function toggleInsert() {
9024 if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
9025 insertActive = !insertActive;
9026 if (insertActive) {
9027 pickActive = false;
9028 hideHighlight();
9029 hideBar();
9030 hideActionPicker();
9031 selectedElement = null;
9032 configureKind = 'replace';
9033 if (state === 'CONFIGURING') cancelInsertConfigure();
9034 else if (state === 'IDLE' || state === 'PICKING') state = 'PICKING';
9035 } else {
9036 clearInsertPicking();
9037 if (state === 'PICKING' && !pickActive) state = 'IDLE';
9038 }
9039 saveInteractionPrefs();
9040 updateGlobalBarState();
9041 syncPageChatFocus('toggle-insert');
9042 }
9043
9044 function loadDetectScript() {
9045 if (detectScriptLoaded) return;
9046 detectScriptLoaded = true;
9047 const s = document.createElement('script');
9048 s.src = 'http://localhost:' + PORT + '/detect.js';
9049 s.dataset.impeccableExtension = 'true';
9050 document.head.appendChild(s);
9051 }
9052
9053 function onDetectMessage(e) {
9054 if (!e.data || typeof e.data.source !== 'string') return;
9055 // Detection script is loaded and ready
9056 if (e.data.source === 'impeccable-ready') {
9057 detectReady = true;
9058 if (detectPendingScan && detectActive) {
9059 detectPendingScan = false;
9060 requestDetectScan();
9061 }
9062 }
9063 // Scan results arrived
9064 if (e.data.source === 'impeccable-results') {
9065 if (!detectActive) return;
9066 if (activeDetectScanId && e.data.scanId !== activeDetectScanId) return;
9067 detectCount = e.data.count || 0;
9068 if (detectActive && pendingDetectScanId && detectCount === 0) {
9069 showToast(DETECT_EMPTY_MESSAGE, 3200);
9070 }
9071 pendingDetectScanId = null;
9072 updateGlobalBarState();
9073 }
9074 }
9075
9076 /** Full teardown: remove all UI, disconnect SSE, clean up. */
9077 function teardown() {
9078 stopAgentStatusPoll();
9079 hideAgentPollTooltip();
9080 if (agentPollTooltipEl) {
9081 agentPollTooltipEl.remove();
9082 agentPollTooltipEl = null;
9083 }
9084 stopVoice({ suppressSubmit: true });
9085 clearSteerFocusRecoverTimer();
9086 steerFocusSuspended = false;
9087 steerFocusPauseUntil = 0;
9088 pagePointerGesture = null;
9089 pagePickSkipClick = false;
9090 cleanup();
9091 hideBar();
9092 if (pendingDockResizeObserver) { pendingDockResizeObserver.disconnect(); pendingDockResizeObserver = null; }
9093 window.removeEventListener('resize', positionPendingDock);
9094 if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
9095 if (pendingDockEl) {
9096 pendingDockEl.remove();
9097 pendingDockEl = null;
9098 pendingPillEl = null;
9099 pendingPillSpinnerEl = null;
9100 pendingPillLabelEl = null;
9101 pendingPillCountEl = null;
9102 pendingTrashBtn = null;
9103 pendingKeepFixingBtn = null;
9104 pendingRollbackBtn = null;
9105 pendingApplyInFlight = false;
9106 }
9107 if (globalBarEl) {
9108 globalBarEl.style.transition = 'none';
9109 globalBarEl.remove();
9110 globalBarEl = null;
9111 }
9112 pageChatEl = null;
9113 pageChatInput = null;
9114 pageChatHint = null;
9115 pageChatVoiceBtn = null;
9116 pageChatExpanded = false;
9117 if (insertCreateTooltipEl) { insertCreateTooltipEl.remove(); insertCreateTooltipEl = null; }
9118 if (highlightEl) { highlightEl.remove(); highlightEl = null; }
9119 if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
9120 if (barEl) { barEl.remove(); barEl = null; }
9121 if (pickerEl) { pickerEl.remove(); pickerEl = null; }
9122 if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
9123 if (editBadgeProxyRoot) { editBadgeProxyRoot.remove(); editBadgeProxyRoot = null; editBadgeProxyByTarget = new Map(); }
9124 if (evtSource) { evtSource.close(); evtSource = null; }
9125 document.removeEventListener('mousemove', handleMouseMove, true);
9126 document.removeEventListener('click', handleClick, true);
9127 document.removeEventListener('keydown', handleKeyDown, true);
9128 window.removeEventListener('message', onDetectMessage);
9129 // Remove detection overlays
9130 window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
9131 state = 'IDLE';
9132 window.__IMPECCABLE_LIVE_INIT__ = false;
9133 console.log('[impeccable] Live mode exited.');
9134 }
9135
9136 //
9137 // Design System Panel - visualizes the project's .impeccable/design.json sidecar
9138 //
9139
9140 const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
9141 const DESIGN_PANEL_WIDTH = 440;
9142
9143 let designHost = null;
9144 let designShadow = null;
9145 let designState = {
9146 open: false,
9147 tab: 'visual', // 'visual' | 'raw'
9148 parsed: null, // parseDesignMd output (frontmatter + body sections)
9149 sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative)
9150 hasMd: false,
9151 hasSidecar: false,
9152 present: null, // true/false once fetch resolves
9153 raw: null, // raw DESIGN.md for the raw tab
9154 mdNewerThanJson: false, // stale-hint flag
9155 loading: false,
9156 error: null,
9157 collapsed: { // narrative-section accordion state
9158 rules: true, dosdonts: true, overview: true,
9159 },
9160 };
9161
9162 function loadDesignPrefs() {
9163 // `open` is intentionally NOT persisted - the panel always starts closed
9164 // so live mode doesn't auto-slide a big panel over the page on startup.
9165 try {
9166 const raw = localStorage.getItem(DESIGN_PREFS_KEY);
9167 if (!raw) return;
9168 const prefs = JSON.parse(raw);
9169 if (prefs.tab === 'visual' || prefs.tab === 'raw') designState.tab = prefs.tab;
9170 if (prefs.collapsed && typeof prefs.collapsed === 'object') {
9171 Object.assign(designState.collapsed, prefs.collapsed);
9172 }
9173 } catch { /* ignore */ }
9174 }
9175
9176 function saveDesignPrefs() {
9177 try {
9178 localStorage.setItem(DESIGN_PREFS_KEY, JSON.stringify({
9179 tab: designState.tab,
9180 collapsed: designState.collapsed,
9181 }));
9182 } catch { /* ignore */ }
9183 }
9184
9185 function initDesignPanel() {
9186 designHost = document.createElement('div');
9187 designHost.id = PREFIX + '-design-host';
9188 Object.assign(designHost.style, {
9189 position: 'fixed', top: '0', left: '0',
9190 width: '0', height: '0',
9191 zIndex: String(Z.bar + 10),
9192 pointerEvents: 'none',
9193 });
9194 designShadow = designHost.attachShadow({ mode: 'open' });
9195
9196 const style = document.createElement('style');
9197 // Theme-match the bar: dark chrome on light pages, light chrome on dark pages.
9198 const theme = detectPageTheme();
9199 style.textContent = designPanelCss(barPaletteForTheme(theme));
9200 designShadow.appendChild(style);
9201
9202 const root = document.createElement('div');
9203 root.className = 'root';
9204 designShadow.appendChild(root);
9205
9206 uiAppend(designHost);
9207 // The host is pointer-events: none; the panel inside the shadow DOM
9208 // manages its own auto/none. Events bubble through the shadow boundary,
9209 // so attaching here silences host-page outside-interaction handlers
9210 // without touching the host's click-through behavior.
9211 defangOutsideHandlers(designHost, { setPointerEvents: false });
9212
9213 loadDesignPrefs();
9214 renderDesignChrome();
9215 if (designState.open) {
9216 fetchDesignSystem();
9217 }
9218 }
9219
9220 // Neutral panel palette - deliberately NOT Impeccable-branded. The panel is
9221 // a viewer of the project's design system, not an Impeccable surface.
9222 const DP = {
9223 canvas: 'oklch(94% 0 0)', // panel background
9224 tile: 'oklch(98.5% 0 0)', // card-on-canvas
9225 tileAlt: 'oklch(96% 0 0)', // subtler tile for inner surfaces
9226 ink: 'oklch(15% 0 0)',
9227 ink2: 'oklch(35% 0 0)',
9228 meta: 'oklch(55% 0 0)',
9229 hairline: 'oklch(88% 0 0)',
9230 hairlineSoft: 'oklch(92% 0 0)',
9231 amber: 'oklch(70% 0.13 65)', // stale-hint accent
9232 amberBg: 'oklch(95% 0.05 80)',
9233 };
9234
9235 function designPanelCss(BP) {
9236 // BP = bar palette (theme-aware, matches the global bar).
9237 // DP = internal content palette (neutral, so tiles render colors true).
9238 return `
9239 :host, .root { all: initial; }
9240 .root {
9241 font-family: ${FONT};
9242 color: ${DP.ink};
9243 pointer-events: none;
9244 }
9245 .root * { box-sizing: border-box; }
9246 button { font: inherit; color: inherit; }
9247
9248 /* Panel shell: chrome matches the bar; body canvas stays neutral */
9249 .panel {
9250 position: fixed; top: 12px; bottom: 72px; right: 12px;
9251 width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px);
9252 background: ${BP.surface};
9253 border: 1.5px solid ${BP.border};
9254 border-radius: 14px;
9255 box-shadow: ${BP.shadow};
9256 display: flex; flex-direction: column;
9257 transform: translateX(calc(100% + 24px));
9258 opacity: 0;
9259 transition: transform 0.35s ${EASE}, opacity 0.25s ${EASE};
9260 pointer-events: none;
9261 overflow: hidden;
9262 }
9263 .panel[data-open="true"] { transform: translateX(0); opacity: 1; pointer-events: auto; }
9264
9265 .panel-header {
9266 display: flex; align-items: center; gap: 10px;
9267 padding: 10px 10px 10px 14px;
9268 background: transparent;
9269 border-bottom: 1px solid ${BP.hairline};
9270 }
9271 .panel-title {
9272 flex: 1; min-width: 0;
9273 font-family: ${MONO};
9274 font-size: 11.5px; font-weight: 600;
9275 letter-spacing: 0.02em;
9276 color: ${BP.text};
9277 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
9278 }
9279 .panel-close {
9280 border: none; background: transparent; color: ${BP.textDim};
9281 width: 26px; height: 26px; border-radius: 7px;
9282 display: inline-flex; align-items: center; justify-content: center;
9283 cursor: pointer; transition: background 0.15s ease, color 0.15s ease;
9284 }
9285 .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; }
9286
9287 .tabs {
9288 display: inline-flex; padding: 2px;
9289 background: ${BP.hairline};
9290 border-radius: 7px;
9291 gap: 2px;
9292 }
9293 .tab {
9294 border: none; background: transparent;
9295 padding: 4px 10px; border-radius: 5px;
9296 font-family: ${MONO};
9297 font-size: 10px; font-weight: 600; letter-spacing: 0.08em;
9298 text-transform: uppercase;
9299 color: ${BP.textDim}; cursor: pointer;
9300 transition: background 0.15s ease, color 0.15s ease;
9301 }
9302 .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; }
9303
9304 .panel-body {
9305 flex: 1; overflow-y: auto;
9306 padding: 12px 12px 20px;
9307 background: ${DP.canvas};
9308 scrollbar-width: thin;
9309 scrollbar-color: ${DP.hairline} transparent;
9310 }
9311 .panel-body::-webkit-scrollbar { width: 8px; }
9312 .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; }
9313
9314 /* States */
9315 .empty, .loading, .error {
9316 margin: 16px 4px;
9317 padding: 28px 20px; text-align: center;
9318 background: ${DP.tile}; border-radius: 14px;
9319 color: ${DP.ink2}; font-size: 13px; line-height: 1.55;
9320 }
9321 .empty strong { color: ${DP.ink}; display: block; margin-bottom: 6px; font-size: 14px; }
9322 .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; }
9323 .error { color: oklch(45% 0.15 25); }
9324
9325 /* Stale hint */
9326 .stale {
9327 display: flex; align-items: center; gap: 8px;
9328 margin: 8px 4px 12px;
9329 padding: 8px 12px;
9330 background: ${DP.amberBg};
9331 border-radius: 10px;
9332 font-size: 11.5px; color: ${DP.ink2};
9333 }
9334 .stale-dot { width: 8px; height: 8px; border-radius: 50%; background: ${DP.amber}; flex-shrink: 0; }
9335 .stale-text { flex: 1; min-width: 0; }
9336 .stale-text strong { color: ${DP.ink}; font-weight: 600; }
9337
9338 /* Parsed-md fallback banner */
9339 .parsed-md-cta {
9340 margin: 8px 4px 14px;
9341 padding: 14px 16px;
9342 background: ${DP.tile};
9343 border: 1px dashed ${DP.hairline};
9344 border-radius: 12px;
9345 font-size: 12px; color: ${DP.ink2}; line-height: 1.55;
9346 }
9347 .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; }
9348 .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; }
9349
9350 /* Tile primitives */
9351 .tile {
9352 position: relative;
9353 background: ${DP.tile};
9354 border-radius: 16px;
9355 padding: 16px;
9356 margin: 0 4px 10px;
9357 }
9358 .tile-row { margin: 0 4px 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
9359 .tile-row .tile { margin: 0; }
9360 .tile-meta {
9361 display: flex; align-items: baseline; justify-content: space-between;
9362 gap: 10px;
9363 font-family: ${MONO};
9364 font-size: 10px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase;
9365 color: ${DP.meta};
9366 }
9367 .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; }
9368
9369 /* Color tile */
9370 .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; }
9371 .c-tile:hover { transform: translateY(-1px); }
9372 .c-hero {
9373 height: 72px; border-radius: 10px; margin-top: 10px;
9374 box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.05);
9375 }
9376 .c-ramp {
9377 display: flex; gap: 0; height: 14px; border-radius: 4px; overflow: hidden;
9378 margin-top: 8px;
9379 box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.04);
9380 }
9381 .c-ramp > span { flex: 1; }
9382 .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; }
9383
9384 /* Type tile */
9385 .t-tile { }
9386 .t-specimen {
9387 margin: 4px 0 6px;
9388 color: ${DP.ink};
9389 line-height: 0.9;
9390 }
9391 .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; }
9392 .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; }
9393
9394 /* Shadow tile */
9395 .s-tile { }
9396 .s-surface {
9397 height: 60px; margin: 8px 2px 10px;
9398 background: ${DP.tile};
9399 border-radius: 10px;
9400 }
9401 .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; }
9402 .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; }
9403
9404 /* Radii strip */
9405 .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
9406 .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; }
9407 .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); }
9408 .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; }
9409 .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; }
9410
9411 /* Component tile (hosts live primitives) */
9412 .cmp-tile { }
9413 .cmp-stage {
9414 margin: 12px -4px 0;
9415 padding: 18px 16px 10px;
9416 border-top: 1px solid ${DP.hairlineSoft};
9417 display: flex; flex-direction: column; align-items: center; justify-content: center;
9418 gap: 14px;
9419 min-height: 68px;
9420 }
9421 .cmp-stage + .cmp-stage { border-top: 1px dashed ${DP.hairlineSoft}; }
9422 .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; }
9423 .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; }
9424
9425 /* Collapsible */
9426 .coll {
9427 margin: 0 4px 8px;
9428 background: ${DP.tile};
9429 border-radius: 12px;
9430 overflow: hidden;
9431 }
9432 .coll-head {
9433 display: flex; align-items: center; gap: 10px;
9434 width: 100%;
9435 padding: 12px 14px;
9436 background: transparent; border: none;
9437 cursor: pointer; text-align: left;
9438 font-family: ${FONT}; font-size: 12.5px; font-weight: 600; color: ${DP.ink};
9439 transition: background 0.12s ease;
9440 }
9441 .coll-head:hover { background: ${DP.tileAlt}; }
9442 .coll-chev {
9443 width: 12px; height: 12px; flex-shrink: 0;
9444 color: ${DP.meta};
9445 transition: transform 0.2s ${EASE};
9446 }
9447 .coll[data-open="true"] .coll-chev { transform: rotate(90deg); }
9448 .coll-count { margin-left: auto; font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; }
9449 .coll-body { padding: 0 14px 14px; display: none; }
9450 .coll[data-open="true"] .coll-body { display: block; }
9451
9452 .rule-card {
9453 padding: 10px 0;
9454 border-top: 1px solid ${DP.hairlineSoft};
9455 }
9456 .rule-card:first-child { border-top: none; padding-top: 2px; }
9457 .rule-card .name { font-size: 11.5px; font-weight: 700; color: ${DP.ink}; margin-bottom: 3px; }
9458 .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; }
9459 .rule-card .body { font-size: 11.5px; color: ${DP.ink2}; line-height: 1.5; }
9460
9461 .coll .dos { display: grid; gap: 0; margin-top: 2px; }
9462 .coll .do, .coll .dont {
9463 position: relative;
9464 padding: 8px 0 8px 22px;
9465 font-size: 11.5px; line-height: 1.5; color: ${DP.ink2};
9466 border-top: 1px solid ${DP.hairlineSoft};
9467 }
9468 .coll .do:first-child, .coll .dont:first-child,
9469 .coll .do:first-of-type { border-top: none; }
9470 .coll .do + .dont { border-top: 1px solid ${DP.hairlineSoft}; }
9471 .coll .do::before, .coll .dont::before {
9472 content: ''; position: absolute; left: 4px; top: 13px;
9473 width: 8px; height: 8px; border-radius: 50%;
9474 }
9475 .coll .do::before { background: oklch(62% 0.16 145); }
9476 .coll .dont::before { background: oklch(58% 0.22 25); }
9477
9478 .coll .overview-body {
9479 font-size: 12px; line-height: 1.55; color: ${DP.ink2};
9480 }
9481 .coll .overview-body .north-star {
9482 display: block; font-family: ${FONT}; font-style: italic;
9483 font-size: 15px; line-height: 1.3; color: ${DP.ink};
9484 margin-bottom: 8px;
9485 }
9486 .coll .overview-body p { margin: 0 0 8px; }
9487 .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; }
9488 .coll .overview-body li { margin-bottom: 3px; }
9489
9490 /* raw tab markdown (unchanged layout, neutralized palette) */
9491 .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; }
9492 .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; }
9493 .md h1 { font-size: 18px; }
9494 .md h2 { font-size: 15px; padding-bottom: 4px; border-bottom: 1px solid ${DP.hairlineSoft}; }
9495 .md h3 { font-size: 13px; }
9496 .md h4 { font-size: 12px; color: ${DP.meta}; }
9497 .md p { margin: 0 0 10px; }
9498 .md ul, .md ol { margin: 0 0 10px; padding-left: 20px; }
9499 .md li { margin-bottom: 4px; }
9500 .md code { font-family: ${MONO}; font-size: 12px; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; }
9501 .md pre { font-family: ${MONO}; font-size: 12px; background: ${DP.canvas}; padding: 10px 12px; border-radius: 8px; overflow-x: auto; margin: 0 0 10px; }
9502 .md pre code { background: none; padding: 0; }
9503 .md strong { font-weight: 700; }
9504 .md em { font-style: italic; }
9505 .md a { color: ${DP.ink}; text-decoration: underline; }
9506 .md hr { border: none; border-top: 1px solid ${DP.hairlineSoft}; margin: 16px 0; }
9507 `;
9508 }
9509
9510 function renderDesignChrome() {
9511 const root = designShadow.querySelector('.root');
9512 root.innerHTML = '';
9513
9514 // (Panel toggle lives in the global bar - no floating FAB.)
9515 // Panel
9516 const panel = document.createElement('aside');
9517 panel.className = 'panel';
9518 panel.setAttribute('data-open', designState.open ? 'true' : 'false');
9519 panel.appendChild(buildDesignHeader());
9520 const body = document.createElement('div');
9521 body.className = 'panel-body';
9522 body.id = 'panel-body';
9523 panel.appendChild(body);
9524 root.appendChild(panel);
9525
9526 renderDesignBody();
9527 }
9528
9529 function buildDesignHeader() {
9530 const header = document.createElement('div');
9531 header.className = 'panel-header';
9532
9533 const title = document.createElement('div');
9534 title.className = 'panel-title';
9535 title.textContent = 'DESIGN.md';
9536 header.appendChild(title);
9537
9538 const tabs = document.createElement('div');
9539 tabs.className = 'tabs';
9540 for (const t of [['visual', 'Visual'], ['raw', 'Raw']]) {
9541 const btn = document.createElement('button');
9542 btn.className = 'tab';
9543 btn.textContent = t[1];
9544 btn.setAttribute('data-active', designState.tab === t[0] ? 'true' : 'false');
9545 btn.addEventListener('click', () => {
9546 if (designState.tab === t[0]) return;
9547 designState.tab = t[0];
9548 saveDesignPrefs();
9549 renderDesignChrome();
9550 if (t[0] === 'raw' && designState.raw === null && !designState.loading) {
9551 fetchDesignSystem(); // raw is part of the same fetch pair
9552 }
9553 });
9554 tabs.appendChild(btn);
9555 }
9556 header.appendChild(tabs);
9557
9558 const close = document.createElement('button');
9559 close.className = 'panel-close';
9560 close.innerHTML = '&#x2715;';
9561 close.setAttribute('aria-label', 'Close panel');
9562 close.addEventListener('click', toggleDesignPanel);
9563 header.appendChild(close);
9564
9565 return header;
9566 }
9567
9568 function toggleDesignPanel() {
9569 if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
9570 designState.open = !designState.open;
9571 renderDesignChrome();
9572 updateGlobalBarState();
9573 if (designState.open && designState.present === null && !designState.loading) {
9574 fetchDesignSystem();
9575 }
9576 }
9577
9578 async function fetchDesignSystem() {
9579 designState.loading = true;
9580 designState.error = null;
9581 renderDesignBody();
9582 try {
9583 const [jsonRes, rawRes] = await Promise.all([
9584 fetch(`http://localhost:${PORT}/design-system.json?token=${TOKEN}`, { cache: 'no-store' }),
9585 fetch(`http://localhost:${PORT}/design-system/raw?token=${TOKEN}`, { cache: 'no-store' }),
9586 ]);
9587 const jsonData = await jsonRes.json();
9588 designState.present = jsonData.present === true;
9589 designState.parsed = jsonData.parsed || null;
9590 designState.sidecar = jsonData.sidecar || null;
9591 designState.hasMd = !!jsonData.hasMd;
9592 designState.hasSidecar = !!jsonData.hasSidecar;
9593 designState.mdNewerThanJson = !!jsonData.mdNewerThanJson;
9594 designState.raw = designState.present && rawRes.ok ? await rawRes.text() : null;
9595 designState.error = jsonData.parseError || jsonData.sidecarError || null;
9596 } catch (err) {
9597 designState.error = err?.message || 'Failed to load design system.';
9598 } finally {
9599 designState.loading = false;
9600 renderDesignChrome(); // refresh title from data
9601 }
9602 }
9603
9604 function renderDesignBody() {
9605 const body = designShadow.querySelector('#panel-body');
9606 if (!body) return;
9607 body.innerHTML = '';
9608
9609 if (designState.loading) {
9610 body.appendChild(msgDiv('loading', 'Loading design system…'));
9611 return;
9612 }
9613 if (designState.error) {
9614 body.appendChild(msgDiv('error', designState.error));
9615 return;
9616 }
9617 if (designState.present === false) {
9618 const empty = document.createElement('div');
9619 empty.className = 'empty';
9620 empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>/impeccable document</code> in your terminal, then re-open this panel.`;
9621 body.appendChild(empty);
9622 return;
9623 }
9624
9625 if (designState.tab === 'raw') {
9626 renderRawTab(body, designState.raw || '');
9627 return;
9628 }
9629
9630 // Visual tab - single unified render path.
9631 if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
9632 if (designState.hasMd && !designState.hasSidecar) {
9633 body.appendChild(renderParsedMdCta());
9634 }
9635 renderDesignVisual(body, designState.parsed, designState.sidecar);
9636 }
9637
9638 function msgDiv(cls, text) {
9639 const d = document.createElement('div');
9640 d.className = cls;
9641 d.textContent = text;
9642 return d;
9643 }
9644
9645 function renderStaleHint() {
9646 const box = document.createElement('div');
9647 box.className = 'stale';
9648 box.innerHTML = `
9649 <span class="stale-dot"></span>
9650 <span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
9651 `;
9652 return box;
9653 }
9654
9655 function renderParsedMdCta() {
9656 const box = document.createElement('div');
9657 box.className = 'parsed-md-cta';
9658 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.`;
9659 return box;
9660 }
9661
9662 // Unified render: merge parsed DESIGN.md frontmatter with sidecar v2
9663
9664 function renderDesignVisual(body, parsed, sidecar) {
9665 const frontmatter = parsed?.frontmatter || {};
9666 const extensions = sidecar?.extensions || {};
9667 const proseColors = parsed?.colors || null;
9668
9669 const colors = buildColorModels(frontmatter.colors, extensions.colorMeta, proseColors);
9670 if (colors.length) renderColorTiles(body, colors);
9671
9672 const types = buildTypographyModels(frontmatter.typography, extensions.typographyMeta);
9673 if (types.length) renderTypeTiles(body, types);
9674
9675 const radii = buildRadiiModels(frontmatter.rounded);
9676 if (radii.length) renderRadiiTile(body, radii);
9677
9678 if (extensions.shadows?.length) renderShadowTiles(body, extensions.shadows);
9679
9680 const components = sidecar?.components || [];
9681 if (components.length) renderComponentTiles(body, components);
9682
9683 // Narrative: sidecar wins if present (richer, agent-curated). Otherwise
9684 // synthesize from prose sections.
9685 const narrative = sidecar?.narrative || synthesizeNarrative(parsed);
9686 if (narrative.rules?.length) body.appendChild(renderRulesCollapsible(narrative.rules));
9687 if ((narrative.dos?.length || narrative.donts?.length)) body.appendChild(renderDosDontsCollapsible(narrative));
9688 if (narrative.overview || narrative.northStar || narrative.keyCharacteristics?.length) {
9689 body.appendChild(renderOverviewCollapsible(narrative));
9690 }
9691
9692 if (body.childElementCount === 0) {
9693 body.appendChild(msgDiv('empty', 'No design system data available.'));
9694 }
9695 }
9696
9697 // Frontmatter primitives + sidecar colorMeta → tile-ready color models.
9698 // A matching prose bullet (when the slug sits in the bullet text) supplies
9699 // description as a last-resort fallback.
9700 function buildColorModels(fmColors, colorMeta, proseColors) {
9701 if (!fmColors) return [];
9702 const meta = colorMeta || {};
9703 return Object.entries(fmColors).map(([key, value]) => {
9704 const m = meta[key] || {};
9705 return {
9706 role: m.role || humanizeKey(key),
9707 name: m.displayName || humanizeKey(key),
9708 value: normalizeCssColor(m.canonical || value),
9709 canonical: m.canonical || null,
9710 description: m.description || findProseDescription(proseColors, key, m.displayName),
9711 tonalRamp: m.tonalRamp || null,
9712 };
9713 });
9714 }
9715
9716 function buildTypographyModels(fmTypography, typographyMeta) {
9717 if (!fmTypography) return [];
9718 const meta = typographyMeta || {};
9719 return Object.entries(fmTypography).map(([key, spec]) => {
9720 const m = meta[key] || {};
9721 const { family, fallback } = splitFontFamily(spec?.fontFamily);
9722 return {
9723 role: key,
9724 name: m.displayName || humanizeKey(key),
9725 family,
9726 fallback,
9727 weight: spec?.fontWeight ?? 400,
9728 // fontStyle isn't in Stitch's frontmatter schema; the sidecar carries
9729 // it when a role is rendered in italic (e.g. display italic).
9730 style: m.style || 'normal',
9731 sampleSize: spec?.fontSize || '1rem',
9732 lineHeight: spec?.lineHeight != null ? String(spec.lineHeight) : '',
9733 letterSpacing: spec?.letterSpacing,
9734 purpose: m.purpose,
9735 };
9736 });
9737 }
9738
9739 function buildRadiiModels(fmRounded) {
9740 if (!fmRounded) return [];
9741 return Object.entries(fmRounded).map(([name, value]) => ({ name, value }));
9742 }
9743
9744 function splitFontFamily(stack) {
9745 if (!stack || typeof stack !== 'string') return { family: '', fallback: '' };
9746 const parts = stack.split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, ''));
9747 return { family: parts[0] || '', fallback: parts.slice(1).join(', ') };
9748 }
9749
9750 function humanizeKey(k) {
9751 return String(k || '').replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
9752 }
9753
9754 function findProseDescription(proseColors, key, displayName) {
9755 if (!proseColors || !proseColors.groups) return null;
9756 const needles = [key, displayName].filter(Boolean).map((s) => s.toLowerCase());
9757 for (const g of proseColors.groups) {
9758 for (const c of g.colors || []) {
9759 const hay = String(c.name || '').toLowerCase();
9760 if (hay && needles.some((n) => hay.includes(n) || n.includes(hay))) {
9761 return c.description || null;
9762 }
9763 }
9764 }
9765 return null;
9766 }
9767
9768 function synthesizeNarrative(parsed) {
9769 if (!parsed) return {};
9770 const md = parsed;
9771 return {
9772 northStar: md.overview?.creativeNorthStar,
9773 overview: (md.overview?.philosophy || []).join(' '),
9774 keyCharacteristics: md.overview?.keyCharacteristics || [],
9775 rules: [
9776 ...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
9777 ...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
9778 ...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
9779 ],
9780 dos: md.dosDonts?.dos || [],
9781 donts: md.dosDonts?.donts || [],
9782 };
9783 }
9784
9785 function renderColorTiles(body, colors) {
9786 for (const c of colors) {
9787 const tile = document.createElement('div');
9788 tile.className = 'tile c-tile';
9789 tile.title = 'Click to copy';
9790 tile.addEventListener('click', () => copyToClipboard(c.value));
9791
9792 const meta = document.createElement('div');
9793 meta.className = 'tile-meta';
9794 meta.innerHTML = `<span class="name">${escapeHtml(c.name || c.role || 'Color')}</span><span>${escapeHtml(c.value || '')}</span>`;
9795 tile.appendChild(meta);
9796
9797 const hero = document.createElement('div');
9798 hero.className = 'c-hero';
9799 hero.style.background = cssSafe(c.value || '');
9800 tile.appendChild(hero);
9801
9802 const ramp = synthesizeRamp(c);
9803 if (ramp.length) {
9804 const r = document.createElement('div');
9805 r.className = 'c-ramp';
9806 r.innerHTML = ramp.map((v) => `<span style="background:${cssSafe(v)}"></span>`).join('');
9807 tile.appendChild(r);
9808 }
9809
9810 if (c.description) {
9811 const d = document.createElement('div');
9812 d.className = 'c-desc';
9813 d.textContent = c.description;
9814 tile.appendChild(d);
9815 }
9816 body.appendChild(tile);
9817 }
9818 }
9819
9820 function synthesizeRamp(c) {
9821 if (c.tonalRamp?.length) return c.tonalRamp;
9822 // If base value is OKLCH, synthesize an 8-step ramp across lightness.
9823 const m = typeof c.value === 'string' && c.value.match(/^oklch\(\s*([\d.]+)%\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.]+))?\s*\)$/i);
9824 if (!m) return [];
9825 const [, , chroma, hue] = m;
9826 const steps = [20, 32, 44, 56, 68, 80, 90, 96];
9827 return steps.map((l) => `oklch(${l}% ${chroma} ${hue})`);
9828 }
9829
9830 function renderTypeTiles(body, types) {
9831 for (const t of types) {
9832 const tile = document.createElement('div');
9833 tile.className = 'tile t-tile';
9834
9835 const meta = document.createElement('div');
9836 meta.className = 'tile-meta';
9837 meta.innerHTML = `<span>${escapeHtml(t.role || '')}</span><span>${escapeHtml(t.weight || '')} ${escapeHtml(t.style === 'italic' ? 'italic' : '')}</span>`;
9838 tile.appendChild(meta);
9839
9840 const specimen = document.createElement('div');
9841 specimen.className = 't-specimen';
9842 specimen.textContent = 'Aa';
9843 specimen.style.fontFamily = fontStack(t);
9844 specimen.style.fontWeight = String(t.weight || 400);
9845 specimen.style.fontStyle = t.style || 'normal';
9846 specimen.style.fontSize = '56px'; // Fixed specimen size - compare faces, not scales.
9847 specimen.style.letterSpacing = 'normal';
9848 specimen.style.textTransform = 'none';
9849 tile.appendChild(specimen);
9850
9851 // The system's actual sample size for this role, shown as small mono meta below.
9852 if (t.sampleSize) {
9853 const scale = document.createElement('div');
9854 scale.style.cssText = 'font-family:' + MONO + '; font-size: 10px; color:' + DP.meta + '; margin-top: 2px;';
9855 scale.textContent = t.sampleSize;
9856 tile.appendChild(scale);
9857 }
9858
9859 const family = document.createElement('div');
9860 family.className = 't-family';
9861 family.textContent = t.family || t.name || '';
9862 tile.appendChild(family);
9863
9864 if (t.purpose) {
9865 const p = document.createElement('div');
9866 p.className = 't-purpose';
9867 p.textContent = t.purpose;
9868 tile.appendChild(p);
9869 }
9870 body.appendChild(tile);
9871 }
9872 }
9873
9874 function fontStack(t) {
9875 const fam = t.family || '';
9876 const fb = t.fallback || '';
9877 if (fam && /[,\s]/.test(fam) && !fam.includes("'") && !fam.includes('"')) {
9878 return `"${fam}", ${fb}`;
9879 }
9880 return fam && fb ? `"${fam}", ${fb}` : (fam || fb);
9881 }
9882
9883 function renderRadiiTile(body, radii) {
9884 const tile = document.createElement('div');
9885 tile.className = 'tile';
9886 const meta = document.createElement('div');
9887 meta.className = 'tile-meta';
9888 meta.innerHTML = `<span class="name">Corner Radii</span><span>${radii.length}</span>`;
9889 tile.appendChild(meta);
9890
9891 const strip = document.createElement('div');
9892 strip.className = 'r-strip';
9893 for (const r of radii) {
9894 const item = document.createElement('div');
9895 item.className = 'r-item';
9896 const s = document.createElement('div');
9897 s.className = 'r-sample';
9898 s.style.borderRadius = r.value || '0';
9899 item.appendChild(s);
9900 const lbl = document.createElement('div');
9901 lbl.className = 'r-label';
9902 lbl.textContent = r.name || '';
9903 item.appendChild(lbl);
9904 const val = document.createElement('div');
9905 val.className = 'r-val';
9906 val.textContent = r.value || '';
9907 item.appendChild(val);
9908 strip.appendChild(item);
9909 }
9910 tile.appendChild(strip);
9911 body.appendChild(tile);
9912 }
9913
9914 function renderShadowTiles(body, shadows) {
9915 for (const sh of shadows) {
9916 const tile = document.createElement('div');
9917 tile.className = 'tile s-tile';
9918
9919 const meta = document.createElement('div');
9920 meta.className = 'tile-meta';
9921 meta.innerHTML = `<span class="name">${escapeHtml(sh.name || 'Shadow')}</span><span>Elevation</span>`;
9922 tile.appendChild(meta);
9923
9924 const surface = document.createElement('div');
9925 surface.className = 's-surface';
9926 surface.style.boxShadow = sh.value || 'none';
9927 tile.appendChild(surface);
9928
9929 const val = document.createElement('div');
9930 val.className = 's-value';
9931 val.textContent = sh.value || '';
9932 tile.appendChild(val);
9933
9934 if (sh.purpose) {
9935 const p = document.createElement('div');
9936 p.className = 's-purpose';
9937 p.textContent = sh.purpose;
9938 tile.appendChild(p);
9939 }
9940 body.appendChild(tile);
9941 }
9942 }
9943
9944 function renderComponentTiles(body, components) {
9945 // Group consecutive components that share a kind into one tile. This avoids
9946 // a pile of one-component tiles (e.g., three button variants = three tiles)
9947 // and reads more like a proper category.
9948 const groups = groupByKind(components);
9949
9950 for (const group of groups) {
9951 const tile = document.createElement('div');
9952 tile.className = 'tile cmp-tile';
9953
9954 const meta = document.createElement('div');
9955 meta.className = 'tile-meta';
9956 const groupTitle = group.length === 1
9957 ? (group[0].name || group[0].kind || 'Component')
9958 : titleForKind(group[0].kind, group.length);
9959 meta.innerHTML = `<span class="name">${escapeHtml(groupTitle)}</span><span class="cmp-kind">${escapeHtml(group[0].kind || '')}</span>`;
9960 tile.appendChild(meta);
9961
9962 for (const c of group) {
9963 const stage = document.createElement('div');
9964 stage.className = 'cmp-stage';
9965
9966 // Render the component in its own shadow root so its CSS can't bleed.
9967 const host = document.createElement('div');
9968 const sub = host.attachShadow({ mode: 'open' });
9969 const style = document.createElement('style');
9970 style.textContent = c.css || '';
9971 sub.appendChild(style);
9972 const container = document.createElement('div');
9973 container.innerHTML = c.html || '';
9974 sub.appendChild(container);
9975 stage.appendChild(host);
9976
9977 // Show component name as a sublabel only when the tile groups >1 item,
9978 // or when the component's display name differs from its kind.
9979 const showSublabel = group.length > 1;
9980 if (showSublabel) {
9981 const lbl = document.createElement('div');
9982 lbl.className = 'cmp-sublabel';
9983 lbl.textContent = c.name || '';
9984 stage.appendChild(lbl);
9985 }
9986 tile.appendChild(stage);
9987 }
9988
9989 // Single shared description if all items carry the same one; otherwise
9990 // skip - per-item descriptions clutter a grouped tile.
9991 if (group.length === 1 && group[0].description) {
9992 const d = document.createElement('div');
9993 d.className = 'c-desc';
9994 d.textContent = group[0].description;
9995 tile.appendChild(d);
9996 }
9997 body.appendChild(tile);
9998 }
9999 }
10000
10001 function groupByKind(components) {
10002 const groups = [];
10003 for (const c of components) {
10004 const last = groups[groups.length - 1];
10005 if (last && last[0].kind && c.kind === last[0].kind) {
10006 last.push(c);
10007 } else {
10008 groups.push([c]);
10009 }
10010 }
10011 return groups;
10012 }
10013
10014 function titleForKind(kind, count) {
10015 const labels = {
10016 button: 'Buttons',
10017 input: 'Inputs',
10018 nav: 'Navigation',
10019 chip: 'Chips',
10020 card: 'Cards',
10021 custom: 'Components',
10022 };
10023 return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components');
10024 }
10025
10026 // Collapsibles.
10027
10028 function buildCollapsible(key, label, count) {
10029 const wrap = document.createElement('div');
10030 wrap.className = 'coll';
10031 wrap.setAttribute('data-open', designState.collapsed[key] ? 'false' : 'true');
10032
10033 const head = document.createElement('button');
10034 head.className = 'coll-head';
10035 head.innerHTML = `
10036 <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>
10037 <span>${escapeHtml(label)}</span>
10038 ${count != null ? `<span class="coll-count">${escapeHtml(String(count))}</span>` : ''}
10039 `;
10040 head.addEventListener('click', () => {
10041 designState.collapsed[key] = !designState.collapsed[key];
10042 saveDesignPrefs();
10043 renderDesignBody();
10044 });
10045 wrap.appendChild(head);
10046
10047 const body = document.createElement('div');
10048 body.className = 'coll-body';
10049 wrap.appendChild(body);
10050 return { wrap, body };
10051 }
10052
10053 function renderRulesCollapsible(rules) {
10054 const { wrap, body } = buildCollapsible('rules', 'Named Rules', rules.length);
10055 for (const r of rules) {
10056 const card = document.createElement('div');
10057 card.className = 'rule-card';
10058 const name = document.createElement('div');
10059 name.className = 'name';
10060 name.innerHTML = `${escapeHtml(r.name)}${r.section ? `<span class="section">${escapeHtml(r.section)}</span>` : ''}`;
10061 card.appendChild(name);
10062 const b = document.createElement('div');
10063 b.className = 'body';
10064 b.textContent = r.body || '';
10065 card.appendChild(b);
10066 body.appendChild(card);
10067 }
10068 return wrap;
10069 }
10070
10071 function renderDosDontsCollapsible(n) {
10072 const total = (n.dos?.length || 0) + (n.donts?.length || 0);
10073 const { wrap, body } = buildCollapsible('dosdonts', "Do's and Don'ts", total);
10074 const grid = document.createElement('div');
10075 grid.className = 'dos';
10076 for (const d of n.dos || []) {
10077 const el = document.createElement('div');
10078 el.className = 'do';
10079 el.innerHTML = inlineMd(d);
10080 grid.appendChild(el);
10081 }
10082 for (const d of n.donts || []) {
10083 const el = document.createElement('div');
10084 el.className = 'dont';
10085 el.innerHTML = inlineMd(d);
10086 grid.appendChild(el);
10087 }
10088 body.appendChild(grid);
10089 return wrap;
10090 }
10091
10092 function renderOverviewCollapsible(n) {
10093 const { wrap, body } = buildCollapsible('overview', 'Overview', null);
10094 const ov = document.createElement('div');
10095 ov.className = 'overview-body';
10096 if (n.northStar) {
10097 const star = document.createElement('span');
10098 star.className = 'north-star';
10099 star.textContent = '“' + n.northStar + '”';
10100 ov.appendChild(star);
10101 }
10102 if (n.overview) {
10103 const p = document.createElement('p');
10104 p.innerHTML = inlineMd(n.overview);
10105 ov.appendChild(p);
10106 }
10107 if (n.keyCharacteristics?.length) {
10108 const ul = document.createElement('ul');
10109 ul.innerHTML = n.keyCharacteristics.map((k) => `<li>${inlineMd(k)}</li>`).join('');
10110 ov.appendChild(ul);
10111 }
10112 body.appendChild(ov);
10113 return wrap;
10114 }
10115
10116 function cssSafe(v) {
10117 // Strip anything outside valid CSS value chars to prevent injection via
10118 // .impeccable/design.json values rendered into inline style strings.
10119 return String(v).replace(/[<>"'`\n]/g, '');
10120 }
10121
10122 function normalizeCssColor(v) {
10123 if (!v || typeof v !== 'string') return v;
10124 const s = v.trim();
10125 const oklch = s.match(/oklch\([^)]+\)/i);
10126 if (oklch) return oklch[0];
10127 const hex = s.match(/#[0-9a-fA-F]{3,8}\b/);
10128 if (hex) return hex[0];
10129 const rgb = s.match(/rgba?\([^)]+\)/i);
10130 if (rgb) return rgb[0];
10131 return s.replace(/\s+#.*$/, '').trim();
10132 }
10133
10134 // Raw tab: minimal markdown renderer (subset)
10135
10136 function renderRawTab(body, md) {
10137 const wrap = document.createElement('div');
10138 wrap.className = 'md';
10139 wrap.innerHTML = renderMarkdown(md);
10140 body.appendChild(wrap);
10141 }
10142
10143 function renderMarkdown(md) {
10144 const lines = md.split(/\r?\n/);
10145 const out = [];
10146 let i = 0;
10147 let inCode = false;
10148 let codeBuf = [];
10149 let paraBuf = [];
10150 let listBuf = []; // array of { indent, html }
10151 let listType = null; // 'ul' | 'ol'
10152
10153 const flushPara = () => {
10154 if (paraBuf.length) {
10155 out.push(`<p>${inlineMd(paraBuf.join(' '))}</p>`);
10156 paraBuf = [];
10157 }
10158 };
10159 const flushList = () => {
10160 if (listBuf.length) {
10161 out.push(buildListHtml(listBuf, listType));
10162 listBuf = [];
10163 listType = null;
10164 }
10165 };
10166 const flushAll = () => { flushPara(); flushList(); };
10167
10168 for (; i < lines.length; i++) {
10169 const line = lines[i];
10170
10171 // Code fence
10172 const fence = line.match(/^```(\w*)\s*$/);
10173 if (fence) {
10174 if (!inCode) { flushAll(); inCode = true; codeBuf = []; }
10175 else {
10176 out.push(`<pre><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`);
10177 inCode = false;
10178 }
10179 continue;
10180 }
10181 if (inCode) { codeBuf.push(line); continue; }
10182
10183 if (line.trim() === '') { flushAll(); continue; }
10184
10185 const hr = line.match(/^\s*(?:---+|\*\*\*+)\s*$/);
10186 if (hr) { flushAll(); out.push('<hr />'); continue; }
10187
10188 const heading = line.match(/^(#{1,4})\s+(.+)$/);
10189 if (heading) {
10190 flushAll();
10191 const lvl = heading[1].length;
10192 out.push(`<h${lvl}>${inlineMd(heading[2])}</h${lvl}>`);
10193 continue;
10194 }
10195
10196 const bullet = line.match(/^(\s*)([-*])\s+(.+)$/);
10197 const ordered = line.match(/^(\s*)(\d+)\.\s+(.+)$/);
10198 if (bullet || ordered) {
10199 flushPara();
10200 const m = bullet || ordered;
10201 const indent = Math.floor(m[1].length / 2);
10202 const t = bullet ? 'ul' : 'ol';
10203 if (listType && listType !== t) flushList();
10204 listType = t;
10205 listBuf.push({ indent, html: inlineMd(m[3]) });
10206 continue;
10207 }
10208
10209 paraBuf.push(line);
10210 }
10211 flushAll();
10212 if (inCode && codeBuf.length) {
10213 out.push(`<pre><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`);
10214 }
10215 return out.join('\n');
10216 }
10217
10218 function buildListHtml(items, type) {
10219 // Nest by indent (one level deep is plenty for DESIGN.md).
10220 let html = `<${type}>`;
10221 let lastIndent = 0;
10222 for (const it of items) {
10223 if (it.indent > lastIndent) html += `<${type}>`;
10224 else if (it.indent < lastIndent) html += `</${type}>`.repeat(lastIndent - it.indent);
10225 html += `<li>${it.html}</li>`;
10226 lastIndent = it.indent;
10227 }
10228 html += `</${type}>`.repeat(lastIndent + 1);
10229 return html;
10230 }
10231
10232 function inlineMd(text) {
10233 // Order matters: escape first, then re-inject tags.
10234 let s = escapeHtml(text);
10235 // Code spans
10236 s = s.replace(/`([^`]+)`/g, (_, code) => `<code>${code}</code>`);
10237 // Links [text](url)
10238 s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `<a href="${u}" target="_blank" rel="noopener noreferrer">${t}</a>`);
10239 // Bold
10240 s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
10241 // Italic (only single *…*, skip if inside bold already handled)
10242 s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>');
10243 return s;
10244 }
10245
10246 function highlightBold(text) {
10247 return inlineMd(text);
10248 }
10249
10250 function escapeHtml(s) {
10251 return String(s)
10252 .replace(/&/g, '&amp;')
10253 .replace(/</g, '&lt;')
10254 .replace(/>/g, '&gt;')
10255 .replace(/"/g, '&quot;')
10256 .replace(/'/g, '&#39;');
10257 }
10258
10259 function copyToClipboard(text) {
10260 if (!text) return;
10261 try {
10262 navigator.clipboard.writeText(text);
10263 showToast('Copied: ' + text);
10264 } catch { /* ignore */ }
10265 }
10266
10267 //
10268 // Init
10269 //
10270
10271 function init() {
10272 try { history.scrollRestoration = 'manual'; } catch {}
10273 initHighlight();
10274 initEditBadge();
10275 initAnnotOverlay();
10276 initBar();
10277 initActionPicker();
10278 initParamsPanel();
10279 initGlobalBar();
10280 attachSteerFocusDebug();
10281 attachSteerFocusGuard();
10282 initDesignPanel();
10283 fetchPendingCount();
10284 document.addEventListener('mousemove', handleMouseMove, true);
10285 document.addEventListener('click', handleClick, true);
10286 document.addEventListener('keydown', handleKeyDown, true);
10287 connectSSE();
10288
10289 // Check for an active session to resume (variant wrapper already in DOM after HMR)
10290 if (!resumeSession()) {
10291 console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
10292 // SvelteKit (and any framework that hydrates after HTML parse) may add
10293 // the variant wrapper AFTER init runs. Watch for it and retry resume
10294 // once it appears. Disconnect on first hit.
10295 const scout = new MutationObserver(() => {
10296 const wrapper = document.querySelector('[data-impeccable-variants]');
10297 if (!wrapper) return;
10298 scout.disconnect();
10299 if (resumeSession()) {
10300 console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
10301 }
10302 });
10303 scout.observe(document.body, { childList: true, subtree: true });
10304 } else {
10305 console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
10306 }
10307
10308 syncPageChatFocus('init-complete');
10309 }
10310
10311 if (document.readyState === 'loading') {
10312 document.addEventListener('DOMContentLoaded', init);
10313 } else {
10314 init();
10315 }
10316 })();
10317
10317 lines JAVASCRIPT