diff --git a/.agents/skills/impeccable/reference/live.md b/.agents/skills/impeccable/reference/live.md index fa7a43c10..bbb4ef8a2 100644 --- a/.agents/skills/impeccable/reference/live.md +++ b/.agents/skills/impeccable/reference/live.md @@ -113,7 +113,9 @@ node .agents/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`. -On accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched. +For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live//manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `' : '')); + if (paramValues && Object.keys(paramValues).length > 0) { + lines.push( + bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close, + ); + } + lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + lines.push(bodyIndent + '
'); + lines.push(...bodyRestored); + lines.push(bodyIndent + '
'); + }; + + if (isJsx) { + const wrapperStyle = 'style={{ display: "contents" }}'; + lines.push(indent + '
'); + pushCarbonizeBody(indent + ' '); + lines.push(indent + '
'); + } else { + pushCarbonizeBody(indent); + } + + return lines; +} + +function reindentContent(contentLines, fromIndent, toIndent) { + return contentLines.map((line) => { + if (line.trim() === '') return ''; + if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length); + return toIndent + line.trimStart(); + }); +} + function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; @@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const needsCarbonize = !!(cssContent || hasHelperAttrs); - // Build the replacement const restored = deindentContent(variantContent, indent); - const replacement = []; - - if (cssContent) { - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); - // JSX targets need the CSS body wrapped in a template literal so that the - // `{` and `}` in CSS rules don't get parsed as JSX expressions. - replacement.push(indent + '' : '')); - if (paramValues && Object.keys(paramValues).length > 0) { - // Preserve the user's knob positions for the carbonize-cleanup agent - // to bake into the final CSS when it collapses scoped rules. - replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); - } - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); - } - - // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the - // carbonize CSS block working visually by re-wrapping the accepted content - // in a data-impeccable-variant="N" div with `display: contents` (so layout - // isn't affected). The carbonize agent strips this attribute + wrapper when - // it moves the CSS to a proper stylesheet. - // - // Style attribute syntax has to follow the host file's flavor — JSX files - // need the object form, otherwise React 19 throws "Failed to set indexed - // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. - if (cssContent) { - const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; - replacement.push(indent + '
'); - replacement.push(...restored); - replacement.push(indent + '
'); - } else { - replacement.push(...restored); - } + const replacement = buildCarbonizeReplacement({ + indent, + commentSyntax, + isJsx, + id, + variantNum, + cssContent, + paramValues, + restored, + }); const newLines = [ ...lines.slice(0, replaceRange.start), @@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; } +function readSourceShadowPreviewMeta(content, id) { + const escaped = escapeRegExp(id); + const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>'); + const match = String(content || '').match(wrapperRe); + if (!match) return null; + const tag = match[0]; + if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null; + const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file'); + const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start')); + const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end')); + if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null; + return { sourceFile, sourceStartLine, sourceEndLine }; +} + +function readHtmlAttr(tag, name) { + const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1')); + if (!match) return null; + return decodeHtmlAttr(match[2]); +} + +function decodeHtmlAttr(value) { + return String(value || '') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- @@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts }; diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index f609ac847..d7c580e2f 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -26,9 +26,9 @@ return; } - // --------------------------------------------------------------------------- + // // Design tokens - // --------------------------------------------------------------------------- + // // Brand kinpaku (gold) is pinned to the site's neo-kinpaku tokens // (see site/styles/kinpaku-tokens.css) so Accept / knobs / cycle-dots / @@ -115,19 +115,48 @@ { value: 'overdrive', label: 'Overdrive' }, ]; - // --------------------------------------------------------------------------- + const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions']; + const LIVE_UI_SURFACES = [ + { 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'] }, + { key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] }, + { key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-configure-input-wrap', PREFIX + '-input', PREFIX + '-configure-voice'] }, + { key: 'action-picker', ids: [PREFIX + '-picker'] }, + { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, + { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, + { key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] }, + { key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] }, + { key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] }, + { 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'] }, + { key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] }, + { key: 'design-system-panel', ids: [PREFIX + '-design-host'] }, + { key: 'toasts-and-errors', ids: [PREFIX + '-toast'] }, + { key: 'css-isolation-boundary', ids: [PREFIX + '-root'] }, + ]; + const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))]; + + // // State - // --------------------------------------------------------------------------- + // let state = 'IDLE'; let hoveredElement = null; let selectedElement = null; let currentSessionId = null; - let pendingAcceptedSession = null; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; + let svelteComponentSession = null; + let svelteRuntimePromise = null; + let pendingSvelteComponentRetryObserver = null; + let currentSourceFile = null; + let currentPreviewFile = null; + let currentPreviewMode = null; + let recoveryWaitingForAnchor = false; + let pendingAcceptedSession = null; let variantObserver = null; + let variantSelectionInFlight = false; + let variantSelectionPromise = null; + let recoveringEmptyCycling = false; let hasProjectContext = false; let selectedAction = 'impeccable'; let selectedCount = 3; @@ -175,14 +204,17 @@ let highlightEl = null; let tooltipEl = null; let barEl = null; + let barHideSeq = 0; let pickerEl = null; let toastEl = null; let scrollRaf = null; let editBadgeEl = null; + let editBadgeProxyRoot = null; + let editBadgeProxyByTarget = new Map(); - // --------------------------------------------------------------------------- + // // Helpers - // --------------------------------------------------------------------------- + // function own(el) { return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); @@ -204,8 +236,105 @@ return s; } + function rectIsUsableAnchor(rect) { + return !!rect && rect.width > 0.5 && rect.height > 0.5; + } + + function makeFrozenAnchor(el) { + if (!el || !el.getBoundingClientRect) return null; + const r = el.getBoundingClientRect(); + if (!rectIsUsableAnchor(r)) return null; + const rect = { + x: r.x, y: r.y, + top: r.top, left: r.left, + right: r.right, bottom: r.bottom, + width: r.width, height: r.height, + }; + return { + __impeccableFrozenAnchor: true, + tagName: el.tagName || 'DIV', + id: el.id || '', + classList: el.classList ? [...el.classList] : [], + hasAttribute: () => false, + getBoundingClientRect: () => rect, + }; + } + function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } + function cssId(id) { + if (window.CSS?.escape) return CSS.escape(id); + return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); + } + + function liveUiRoot() { + const root = window.__IMPECCABLE_LIVE_UI_ROOT__; + if (root && typeof root.appendChild === 'function') return root; + return document.body; + } + + function uiAppend(el) { + liveUiRoot().appendChild(el); + return el; + } + + function uiAppendStyle(styleEl) { + const root = liveUiRoot(); + if (root && root !== document.body) root.appendChild(styleEl); + else document.head.appendChild(styleEl); + return styleEl; + } + + function uiGetById(id) { + const root = liveUiRoot(); + if (root?.getElementById) { + const found = root.getElementById(id); + if (found) return found; + } + if (root?.querySelector) { + const found = root.querySelector('#' + cssId(id)); + if (found) return found; + } + return document.getElementById(id); + } + + function activeElementDeep() { + let active = document.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; + return active; + } + + window.__IMPECCABLE_LIVE_CHROME_CORE__ = { + version: 1, + adapter: window.__IMPECCABLE_LIVE_ADAPTER__ || 'dom', + mountContract: LIVE_CHROME_MOUNT_CONTRACT, + surfaces: LIVE_UI_SURFACES, + componentIds: LIVE_UI_COMPONENT_IDS, + root: liveUiRoot, + append: uiAppend, + appendStyle: uiAppendStyle, + getById: uiGetById, + activeElementDeep, + debugState: () => ({ + state, + currentSessionId, + expectedVariants, + arrivedVariants, + visibleVariant, + savedSession: loadSession(), + sourceFile: currentSourceFile, + previewFile: currentPreviewFile, + previewMode: currentPreviewMode, + barText: barEl?.textContent || null, + barConnected: !!barEl?.isConnected, + hasSvelteComponentSession: !!svelteComponentSession, + mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0, + pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver, + recoveryWaitingForAnchor, + evtSourceReadyState: evtSource ? evtSource.readyState : null, + }), + }; + // Modal-aware chrome: keep our floating UI clickable inside Radix / // Headless UI / vaul portals. // @@ -245,9 +374,9 @@ rootEl.addEventListener('focusin', stop); } - // --------------------------------------------------------------------------- + // // Highlight overlay - // --------------------------------------------------------------------------- + // function initHighlight() { highlightEl = document.createElement('div'); @@ -259,7 +388,7 @@ transition: HIGHLIGHT_TRANSITION, display: 'none', opacity: '0', }); - document.body.appendChild(highlightEl); + uiAppend(highlightEl); tooltipEl = document.createElement('div'); tooltipEl.id = PREFIX + '-tooltip'; @@ -273,7 +402,7 @@ letterSpacing: '0.02em', transition: TOOLTIP_TRANSITION, }); - document.body.appendChild(tooltipEl); + uiAppend(tooltipEl); } function showHighlight(el) { @@ -310,7 +439,7 @@ if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; } } - // --------------------------------------------------------------------------- + // // Annotation overlay (comment pins + kinpaku strokes) // // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned @@ -318,7 +447,7 @@ // drag) drops a comment pin; drag paints a kinpaku SVG stroke. All coords // are stored in element-local CSS px so they survive scroll / resize and // correlate directly with the captured PNG. - // --------------------------------------------------------------------------- + // const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it @@ -397,7 +526,7 @@ annotOverlayEl.addEventListener('pointermove', onAnnotMove); annotOverlayEl.addEventListener('pointerup', onAnnotUp); annotOverlayEl.addEventListener('pointercancel', onAnnotUp); - document.body.appendChild(annotOverlayEl); + uiAppend(annotOverlayEl); // Modal-host friendliness: pointer-events is already 'auto' on this // overlay; we only need to silence the host's outside-interaction // listeners. Don't override pointer-events here (the overlay toggles @@ -828,9 +957,9 @@ return wrap; } - // --------------------------------------------------------------------------- + // // Element context extraction - // --------------------------------------------------------------------------- + // function stripManualEditRuntimeState(root) { if (!root || root.nodeType !== 1) return; @@ -971,9 +1100,9 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } - // --------------------------------------------------------------------------- + // // The Bar - one floating element, three modes - // --------------------------------------------------------------------------- + // // Contextual-bar palette. Cached at init so every build*Row reads a // consistent set of colors; detectPageTheme runs once rather than on every @@ -1006,7 +1135,7 @@ padding: '6px', maxWidth: '520px', minWidth: '320px', }); - document.body.appendChild(barEl); + uiAppend(barEl); defangOutsideHandlers(barEl); } @@ -1041,6 +1170,8 @@ } function showBar(mode) { + barHideSeq += 1; + if (mode === 'cycling' && !ensureCyclingRenderable('show-bar')) return; barEl.innerHTML = ''; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); @@ -1058,11 +1189,12 @@ function hideBar() { if (!barEl) return; + const hideSeq = ++barHideSeq; stopVoice({ suppressSubmit: true }); if (configureKind === 'insert') clearInsertPicking(); barEl.style.opacity = '0'; barEl.style.transform = 'translateY(6px)'; - setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); + setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); if (state === 'EDITING') restoreInlineEditDrafts(); @@ -1071,6 +1203,7 @@ function updateBarContent(mode) { if (!barEl || barEl.style.display === 'none') return; + if (mode === 'cycling' && !ensureCyclingRenderable('update-bar')) return; barEl.innerHTML = ''; // Reset bar styling to the kinpaku picker palette barEl.style.background = BP.surface; @@ -1090,13 +1223,13 @@ syncPageChatFocus('update-bar-content'); } - // --- Configure row --- + // Configure row function syncConfigureInputChrome() { - const wrap = document.getElementById(PREFIX + '-configure-input-wrap'); - const input = document.getElementById(PREFIX + '-input'); + const wrap = uiGetById(PREFIX + '-configure-input-wrap'); + const input = uiGetById(PREFIX + '-input'); if (!wrap || !input) return; - const focused = document.activeElement === input; + const focused = activeElementDeep() === input; wrap.dataset.inputFocused = focused ? 'true' : 'false'; wrap.dataset.voiceListening = (voiceListening && voiceCtx?.mode === 'configure') ? 'true' : 'false'; wrap.style.borderColor = (voiceListening && voiceCtx?.mode === 'configure') @@ -1104,7 +1237,7 @@ : (focused ? BP.accentSoft : BP.hairline); } - // --- Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) --- + // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) function detectInsertAxisFromStyle(style) { const display = style?.display || 'block'; @@ -1377,7 +1510,7 @@ display: 'none', opacity: '0.9', }); - document.body.appendChild(insertLineEl); + uiAppend(insertLineEl); defangOutsideHandlers(insertLineEl); return insertLineEl; } @@ -1440,6 +1573,10 @@ /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { + if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor) return anchor; + } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (wrapper) { @@ -1557,6 +1694,11 @@ positionBar(); } + function showOrUpdateCyclingBar() { + if (barEl && barEl.style.display !== 'none') updateBarContent('cycling'); + else showBar('cycling'); + } + function buildPlaceholderResizeHandles() { if (!placeholderResizeLayerEl) return; placeholderResizeLayerEl.innerHTML = ''; @@ -1665,7 +1807,7 @@ } function isInsertCreateEnabled(btn) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); + btn = btn || uiGetById(PREFIX + '-insert-create'); return !!btn && btn.getAttribute('aria-disabled') !== 'true'; } @@ -1691,7 +1833,7 @@ lineHeight: '1.35', }); insertCreateTooltipEl.id = PREFIX + '-insert-create-tooltip'; - document.body.appendChild(insertCreateTooltipEl); + uiAppend(insertCreateTooltipEl); return insertCreateTooltipEl; } @@ -1723,8 +1865,8 @@ } function syncInsertCreateButton(btn, input) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); - input = input || document.getElementById(PREFIX + '-insert-input'); + btn = btn || uiGetById(PREFIX + '-insert-create'); + input = input || uiGetById(PREFIX + '-insert-input'); if (!btn || !input) return; const gate = insertCreateGateState(input); const ok = canCreateInsert(gate); @@ -1833,7 +1975,7 @@ voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!document.getElementById(PREFIX + '-configure-input-style')) { + if (!uiGetById(PREFIX + '-configure-input-style')) { const s = document.createElement('style'); s.id = PREFIX + '-configure-input-style'; s.textContent = @@ -1842,7 +1984,7 @@ '#' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: impeccable-configure-voice-pulse 1.1s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-configure-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } input.addEventListener('focus', () => syncConfigureInputChrome()); @@ -1949,6 +2091,9 @@ transition: 'border-color 0.15s ease', }); inputWrap.id = PREFIX + '-insert-input-wrap'; + inputWrap.addEventListener('pointerdown', (e) => e.stopPropagation()); + inputWrap.addEventListener('mousedown', (e) => e.stopPropagation()); + inputWrap.addEventListener('click', (e) => e.stopPropagation()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; @@ -1984,6 +2129,12 @@ voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; input.addEventListener('input', () => syncInsertCreateButton()); + input.addEventListener('pointerdown', (e) => e.stopPropagation()); + input.addEventListener('mousedown', (e) => e.stopPropagation()); + input.addEventListener('click', (e) => { + e.stopPropagation(); + try { input.focus({ preventScroll: true }); } catch { input.focus(); } + }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); @@ -2049,6 +2200,7 @@ }); create.addEventListener('mouseleave', hideInsertCreateTooltip); create.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); if (controlsLocked) { showManualApplyBusyToast(); return; } if (!isInsertCreateEnabled(create)) return; @@ -2060,7 +2212,7 @@ return row; } - // --- Generating row --- + // Generating row function buildGeneratingRow() { const row = el('div', { @@ -2086,19 +2238,24 @@ }); // Variants currently arrive atomically in a single file edit, so a // per-variant counter would lie. Say what's true. - status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + expectedVariants + ' variants...' - : 'Done'; + status.textContent = recoveryWaitingForAnchor + ? 'Variants ready. Reveal the selected element to resume.' + : (arrivedVariants < expectedVariants + ? 'Generating ' + expectedVariants + ' variants...' + : 'Done'); row.appendChild(status); return row; } - // --- Cycling row --- + // Cycling row const TUNE_ICON_SVG = ''; function buildCyclingRow() { + if (!ensureCyclingRenderable('build-cycling-row')) { + return el('div', { display: 'none' }); + } const row = el('div', { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 2px', @@ -2106,6 +2263,7 @@ // Prev const prev = navBtn('\u2190'); + prev.id = PREFIX + '-variant-prev'; prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); }); if (visibleVariant <= 1) prev.style.opacity = '0.3'; row.appendChild(prev); @@ -2118,11 +2276,13 @@ fontFamily: MONO, fontSize: '11px', fontWeight: '500', color: BP.textDim, minWidth: '24px', textAlign: 'center', }); + counter.id = PREFIX + '-variant-counter'; counter.textContent = visibleVariant + '/' + arrivedVariants; row.appendChild(counter); // Next const next = navBtn('\u2192'); + next.id = PREFIX + '-variant-next'; next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); }); if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3'; row.appendChild(next); @@ -2208,9 +2368,9 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders - // --- Saving row (waiting for agent to process accept/discard) --- + // Saving row (waiting for agent to process accept/discard) function buildSavingRow() { const row = el('div', { @@ -2235,7 +2395,7 @@ return row; } - // --- Confirmed row (green success, auto-dismisses) --- + // Confirmed row (green success, auto-dismisses) function buildConfirmedRow() { const row = el('div', { @@ -2256,7 +2416,7 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders function buildDots(clickable) { const container = el('div', { @@ -2290,10 +2450,7 @@ const idx = i; dot.addEventListener('click', (e) => { e.stopPropagation(); - visibleVariant = idx; - showVariantInDOM(currentSessionId, idx); - updateSelectedElement(); - updateBarContent('cycling'); + selectVariant(idx, 'variant_changed'); }); } container.appendChild(dot); @@ -2323,13 +2480,14 @@ function el(tag, styles) { const e = document.createElement(tag); + if (String(tag).toLowerCase() === 'button') e.type = 'button'; if (styles) Object.assign(e.style, styles); return e; } - // --------------------------------------------------------------------------- + // // Action picker popover - // --------------------------------------------------------------------------- + // function initActionPicker() { const P = barPaletteForTheme(detectPageTheme()); @@ -2384,16 +2542,20 @@ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent'; }); chip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + const prompt = uiGetById(PREFIX + '-input')?.value || ''; selectedAction = action.value; hideActionPicker(); updateBarContent('configure'); + const input = uiGetById(PREFIX + '-input'); + if (input && prompt) input.value = prompt; }); grid.appendChild(chip); }); pickerEl.appendChild(grid); - document.body.appendChild(pickerEl); + uiAppend(pickerEl); defangOutsideHandlers(pickerEl); // Cache the palette on the picker so toggleActionPicker's state refresh @@ -2433,7 +2595,33 @@ setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180); } - // --------------------------------------------------------------------------- + function ensureCyclingRenderable(reason) { + if (arrivedVariants > 0) { + if (visibleVariant < 1 || visibleVariant > arrivedVariants) visibleVariant = 1; + return true; + } + recoverEmptyCycling(reason); + return false; + } + + function recoverEmptyCycling(reason) { + if (recoveringEmptyCycling) return; + recoveringEmptyCycling = true; + try { + console.warn('[impeccable] Refusing to render empty variant cycling state:', reason); + const message = 'No variants were mounted. Please try again.'; + if (svelteComponentSession?.sessionId === currentSessionId) { + abortSvelteComponentInjection(currentSessionId, message); + return; + } + cleanup(); + showToast(message, 5000); + } finally { + recoveringEmptyCycling = false; + } + } + + // // Params panel (per-variant coarse controls) // // Variants may declare a parameter manifest via a JSON attribute on the @@ -2446,13 +2634,13 @@ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped // CSS can respond instantly without regeneration: // - // range / numeric toggle → CSS var (`--p-`) used via var(--p-foo, N) + // range / numeric toggle -> CSS custom property used by variant styles // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."] // // On variant switch, values reset to that variant's declared defaults. // On accept, current values are sent in the event payload so the agent // can bake them into the source-file write. - // --------------------------------------------------------------------------- + // let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide) let paramsPanelInner = null; // translating content (carries bg, padding, knobs) @@ -2507,7 +2695,7 @@ }); paramsPanelEl.appendChild(paramsPanelBody); - document.body.appendChild(paramsPanelEl); + uiAppend(paramsPanelEl); // Don't override pointer-events: the panel toggles between 'none' (closed, // click-through) and 'auto' (open) on its own. Just silence the host's // outside-interaction listeners while the panel is open. @@ -2516,14 +2704,40 @@ } + function getMountedSvelteComponentAnchor(session = svelteComponentSession) { + const el = session?.mountTargetEl?.firstElementChild || null; + if (!el || !document.body.contains(el)) return null; + return rectIsUsableAnchor(el.getBoundingClientRect()) ? el : null; + } + + function resolveSvelteComponentAnchor(session = svelteComponentSession) { + return getMountedSvelteComponentAnchor(session) + || session?.swapAnchor + || null; + } + function getVisibleVariantEl() { if (!currentSessionId) return null; + if (svelteComponentSession?.sessionId === currentSessionId) { + return resolveSvelteComponentAnchor() + || svelteComponentSession.wrapperEl + || null; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } function parseVariantParams(variantEl) { + // Svelte component variants can't carry a `data-impeccable-params` attribute: + // the compiler reads `{` inside attribute values as expression delimiters, so + // JSON-with-braces breaks the build. For that path the params live in a sidecar + // params.json keyed by variant number, loaded into the session at mount time. + if (svelteComponentSession?.sessionId === currentSessionId) { + const byVariant = svelteComponentSession.paramsByVariant || {}; + const params = byVariant[String(visibleVariant)] || byVariant[visibleVariant]; + return Array.isArray(params) ? params : []; + } if (!variantEl) return []; const raw = variantEl.getAttribute('data-impeccable-params'); if (!raw) return []; @@ -2685,11 +2899,11 @@ } } - // --------------------------------------------------------------------------- + // // Inline text editing - makes pure-text descendants of the picked element // directly contenteditable. Save stages copy edits in the live buffer; the // Apply copy edits dock later asks the AI to apply the staged batch. - // --------------------------------------------------------------------------- + // let inlineEditRows = []; let inlineEditDrafts = new Map(); @@ -2803,7 +3017,7 @@ function disableInlineEdit(opts = {}) { for (const row of inlineEditRows) { - if (document.activeElement === row.el) row.el.blur(); + if (activeElementDeep() === row.el) row.el.blur(); row.el.removeAttribute('contenteditable'); delete row.el.dataset.impeccableEditable; delete row.el.dataset.impeccableOriginalText; @@ -3133,7 +3347,7 @@ if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) { showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); } else { - showToast('Save failed: retry or cancel', 4000); + showToast('Save failed - retry or cancel', 4000); } } } @@ -3181,11 +3395,11 @@ } function ensureSpinKeyframes() { - if (document.getElementById(PREFIX + '-keyframes')) return; + if (uiGetById(PREFIX + '-keyframes')) return; const style = document.createElement('style'); style.id = PREFIX + '-keyframes'; style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }'; - document.head.appendChild(style); + uiAppendStyle(style); } function pendingApplyLabel(count) { @@ -3318,10 +3532,10 @@ closeTunePopover(); } if (barEl && barEl.style.display !== 'none' && state === 'CONFIGURING') { - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value : ''; updateBarContent('configure'); - const nextInput = document.getElementById(PREFIX + '-input'); + const nextInput = uiGetById(PREFIX + '-input'); if (nextInput) nextInput.value = prompt; } if (editBadgeEl && editBadgeEl.style.display !== 'none') { @@ -3455,19 +3669,19 @@ updatePendingCounter(remaining); if (result.failed && result.failed.length > 0) { console.warn('[impeccable] some copy edits failed:', result.failed); - showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed - see console', 5000); } else { const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0); if (n > 0) { showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); } else { console.warn('[impeccable] apply returned no verified edits:', result); - showToast('No edits applied, see console', 4000); + showToast('No edits applied - see console', 4000); } } } catch (err) { console.error('[impeccable] commit failed:', err); - showToast('Apply failed, see console', 4000); + showToast('Apply failed - see console', 4000); } finally { if (waitForSseCompletion) return; const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0; @@ -3497,7 +3711,7 @@ } } catch (err) { console.error('[impeccable] discard failed:', err); - showToast('Discard failed, see console', 4000); + showToast('Discard failed - see console', 4000); } } @@ -3645,7 +3859,7 @@ const failedCount = numberOrNull(msg.failedCount) || 0; const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0; if (failedCount > 0) { - showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000); + showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed - see console', 5000); } else if (appliedCount > 0) { showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500); } @@ -3799,9 +4013,164 @@ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); } - // --------------------------------------------------------------------------- + // // Edit content badge - floating button at element top-right to enter EDITING mode - // --------------------------------------------------------------------------- + // + + function usesShadowChromeRoot() { + const root = liveUiRoot(); + return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; + } + + function setImportantStyle(el, name, value) { + el.style.setProperty(name, value, 'important'); + } + + function initEditBadgeHitProxies() { + if (!usesShadowChromeRoot() || editBadgeProxyRoot) return; + editBadgeProxyRoot = document.createElement('div'); + editBadgeProxyRoot.id = PREFIX + '-edit-badge-hit-proxies'; + editBadgeProxyRoot.setAttribute('aria-hidden', 'true'); + const styles = { + all: 'initial', + position: 'fixed', + inset: '0', + width: '100vw', + height: '100vh', + zIndex: String(Z.toast + 1), + pointerEvents: 'none', + background: 'transparent', + overflow: 'visible', + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + document.body.appendChild(editBadgeProxyRoot); + } + + function styleEditBadgeProxy(proxy, target) { + const rect = target.getBoundingClientRect(); + const cursor = getComputedStyle(target).cursor || 'pointer'; + const styles = { + all: 'initial', + position: 'fixed', + left: rect.left + 'px', + top: rect.top + 'px', + width: rect.width + 'px', + height: rect.height + 'px', + margin: '0', + padding: '0', + border: '0', + borderRadius: '0', + background: 'transparent', + color: 'transparent', + opacity: '0.001', + pointerEvents: 'auto', + cursor, + zIndex: String(Z.toast + 2), + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(proxy, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + } + + function proxyMouseEvent(type, source, target) { + let event; + try { + event = new MouseEvent(type, { + bubbles: type !== 'mouseenter' && type !== 'mouseleave', + cancelable: true, + composed: true, + clientX: source.clientX, + clientY: source.clientY, + screenX: source.screenX, + screenY: source.screenY, + button: source.button || 0, + buttons: source.buttons || 0, + ctrlKey: source.ctrlKey, + metaKey: source.metaKey, + shiftKey: source.shiftKey, + altKey: source.altKey, + }); + target.dispatchEvent(event); + } catch {} + } + + function bindEditBadgeProxy(proxy, target) { + const stop = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + proxy.addEventListener('mouseenter', (event) => { + stop(event); + proxyMouseEvent('mouseenter', event, target); + proxyMouseEvent('mouseover', event, target); + }); + proxy.addEventListener('mouseleave', (event) => { + stop(event); + proxyMouseEvent('mouseleave', event, target); + proxyMouseEvent('mouseout', event, target); + }); + proxy.addEventListener('mousedown', (event) => { + stop(event); + target.focus?.({ preventScroll: true }); + proxyMouseEvent('mousedown', event, target); + }); + proxy.addEventListener('mouseup', (event) => { + stop(event); + proxyMouseEvent('mouseup', event, target); + }); + proxy.addEventListener('click', (event) => { + stop(event); + target.click(); + syncEditBadgeHitProxies(); + }); + } + + function editBadgeProxyTargets() { + if (!usesShadowChromeRoot() || !editBadgeEl || editBadgeEl.style.display === 'none') return []; + return [...editBadgeEl.querySelectorAll('button')].filter((target) => { + if (target.disabled) return false; + const rect = target.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return false; + const style = getComputedStyle(target); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); + } + + function syncEditBadgeHitProxies() { + if (!usesShadowChromeRoot()) { + if (editBadgeProxyRoot) editBadgeProxyRoot.remove(); + editBadgeProxyRoot = null; + editBadgeProxyByTarget = new Map(); + return; + } + initEditBadgeHitProxies(); + if (!editBadgeProxyRoot) return; + const targets = editBadgeProxyTargets(); + const active = new Set(targets); + for (const [target, proxy] of editBadgeProxyByTarget) { + if (!active.has(target) || !target.isConnected) { + proxy.remove(); + editBadgeProxyByTarget.delete(target); + } + } + for (const target of targets) { + let proxy = editBadgeProxyByTarget.get(target); + if (!proxy) { + proxy = document.createElement('button'); + proxy.type = 'button'; + proxy.tabIndex = -1; + proxy.dataset.impeccableEditBadgeProxy = 'true'; + proxy.setAttribute('aria-hidden', 'true'); + bindEditBadgeProxy(proxy, target); + editBadgeProxyRoot.appendChild(proxy); + editBadgeProxyByTarget.set(target, proxy); + } + proxy.title = target.title || target.textContent || 'Edit copy'; + styleEditBadgeProxy(proxy, target); + } + } function initEditBadge() { editBadgeEl = document.createElement('div'); @@ -3813,10 +4182,11 @@ display: 'none', userSelect: 'none', }); - document.body.appendChild(editBadgeEl); + uiAppend(editBadgeEl); + initEditBadgeHitProxies(); // Remove focus rings on edit badge buttons + contenteditable elements - if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + if (!uiGetById(PREFIX + '-edit-badge-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-edit-badge-focus-style'; s.textContent = @@ -3826,21 +4196,26 @@ '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; - document.head.appendChild(s); + uiAppendStyle(s); } } function positionEditBadge() { - if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') { + syncEditBadgeHitProxies(); + return; + } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { if (editBadgeEl) editBadgeEl.style.display = 'none'; + syncEditBadgeHitProxies(); return; } editBadgeEl.style.display = 'flex'; @@ -4047,7 +4422,7 @@ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN; } // Re-render the bar so the Tune chip picks up the active styling. - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } function closeTunePopover() { @@ -4055,13 +4430,13 @@ hideParamsPanel(); if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT; if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') { - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } } - // --------------------------------------------------------------------------- + // // Variant cycling in DOM - // --------------------------------------------------------------------------- + // function isVariantShown(el) { if (!el) return false; @@ -4081,9 +4456,42 @@ } } - function showVariantInDOM(sessionId, num) { + function scheduleCyclingBarSync(sessionId, variantNum) { + requestAnimationFrame(() => { + if (state !== 'CYCLING') return; + if (currentSessionId !== sessionId) return; + if (visibleVariant !== variantNum) return; + showOrUpdateCyclingBar(); + syncCyclingControls(); + positionBar(); + }); + } + + function syncCyclingControls() { + const shown = svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0 + ? svelteComponentSession.mountedVariant + : visibleVariant; + const counter = uiGetById(PREFIX + '-variant-counter'); + if (counter && arrivedVariants > 0) counter.textContent = shown + '/' + arrivedVariants; + const prev = uiGetById(PREFIX + '-variant-prev'); + const next = uiGetById(PREFIX + '-variant-next'); + if (prev) prev.style.opacity = shown <= 1 ? '0.3' : '1'; + if (next) next.style.opacity = shown >= arrivedVariants ? '0.3' : '1'; + if (currentSessionId && state === 'CYCLING') saveSession(); + } + + async function showVariantInDOM(sessionId, num) { + if (svelteComponentSession?.sessionId === sessionId) { + visibleVariant = num; + const mounted = await mountSvelteComponentVariant(num); + if (!mounted) return false; + updateSelectedElement(); + refreshParamsPanel(); + scheduleCyclingBarSync(sessionId, num); + return true; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); - if (!wrapper) return; + if (!wrapper) return false; for (const child of wrapper.children) { const v = child.dataset ? child.dataset.impeccableVariant : null; if (!v) continue; @@ -4093,6 +4501,378 @@ // CYCLING yet, the subsequent CYCLING transition triggers its own // refresh) and every cycle step. refreshParamsPanel(); + return true; + } + + function isSvelteComponentManifestPath(filePath) { + return String(filePath || '').endsWith('manifest.json'); + } + + function parseOriginalMarkupElement(originalMarkup) { + const parser = new DOMParser(); + const doc = parser.parseFromString('
' + originalMarkup + '
', 'text/html'); + return doc.getElementById('impeccable-anchor')?.firstElementChild || null; + } + + function findLiveElementForOriginalMarkup(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const tag = origContent.tagName.toLowerCase(); + const cls = origContent.className; + let liveEl = null; + if (origContent.id) { + liveEl = document.getElementById(origContent.id); + } else if (cls) { + const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + for (const c of candidates) { + if (c.className === cls && !own(c)) { liveEl = c; break; } + } + if (!liveEl) { + const expectedClasses = String(cls).split(/\s+/).filter(Boolean); + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } + } + } + return liveEl; + } + + function isSvelteInsertManifest(manifest) { + return manifest?.previewMode === 'svelte-component' && manifest?.mode === 'insert'; + } + + function findLiveElementForSvelteManifest(manifest) { + if (isSvelteInsertManifest(manifest)) { + const anchor = findInsertAnchorInDom(); + if (anchor?.parentElement) return anchor; + } + return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function loadSvelteRuntime(runtimeModule) { + const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js'; + const url = new URL(modulePath, location.origin).href; + if (!svelteRuntimePromise) { + svelteRuntimePromise = import(/* @vite-ignore */ url); + } + return svelteRuntimePromise; + } + + // Svelte component variants declare their params in a sidecar params.json under + // componentDir (keyed by variant number), because a `data-impeccable-params` + // attribute with JSON braces can't survive the Svelte compiler. Returns a map of + // { "1": [...params], "2": [...] }; an empty object when the agent declared none. + async function loadSvelteComponentParams(manifest) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir) return {}; + const paramsPath = dir + '/params.json'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(paramsPath); + try { + const res = await fetch(url); + if (!res.ok) return {}; + const parsed = JSON.parse(await res.text()); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out = {}; + for (const [key, value] of Object.entries(parsed)) { + if (Array.isArray(value)) out[String(key)] = value; + } + return out; + } catch { + return {}; + } + } + + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { + const contract = manifest?.propContract || []; + const values = {}; + if (!liveEl || contract.length === 0) return values; + const sourceOriginal = parseOriginalMarkupElement(manifest.originalMarkup || ''); + if (!sourceOriginal) return values; + const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl); + for (const entry of contract) { + const token = '{' + entry.expr + '}'; + values[entry.prop] = map.get(token) || ''; + } + return values; + } + + async function mountSvelteComponentVariant(variantNum) { + if (!svelteComponentSession || !variantNum) return false; + const { manifest, mountTargetEl, sessionId } = svelteComponentSession; + try { + const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement; + svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null; + const runtime = await loadSvelteRuntime(manifest.runtimeModule); + const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte'; + const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now(); + const mod = await import(/* @vite-ignore */ moduleUrl); + const Component = mod.default; + if (svelteComponentSession.mountedInstance && runtime.unmount) { + await runtime.unmount(svelteComponentSession.mountedInstance); + svelteComponentSession.mountedInstance = null; + } + svelteComponentSession.mountedInstance = runtime.mount(Component, { + target: mountTargetEl, + props: { ...svelteComponentSession.propValues }, + intro: false, + }); + svelteComponentSession.mountedVariant = variantNum; + svelteComponentSession.runtime = runtime; + if (state === 'CYCLING') syncCyclingControls(); + const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (nextAnchor) { + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(nextAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = nextAnchor; + } else { + requestAnimationFrame(() => { + if (svelteComponentSession?.sessionId !== sessionId) return; + const settledAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!settledAnchor) return; + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(settledAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = settledAnchor; + }); + } + return true; + } catch (err) { + if (svelteComponentSession?.sessionId === sessionId) { + svelteComponentSession.swapAnchor = null; + } + console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err); + return false; + } + } + + function teardownSvelteComponentSession(restoreOriginal) { + if (!svelteComponentSession) return; + const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + if (restoreOriginal && detachedOriginal && wrapperEl?.parentElement) { + wrapperEl.parentElement.replaceChild(detachedOriginal, wrapperEl); + } else if (wrapperEl?.parentElement) { + wrapperEl.remove(); + } + svelteComponentSession = null; + svelteRuntimePromise = null; + } + + function applyOriginalAttrsToSvelteAnchor(el, originalMarkup) { + if (!el || !originalMarkup) return; + const original = parseOriginalMarkupElement(originalMarkup); + if (!original || original.tagName !== el.tagName) return; + for (const attr of original.attributes) { + if (attr.name === 'class') { + for (const className of attr.value.split(/\s+/).filter(Boolean)) { + el.classList.add(className); + } + } else if (!el.hasAttribute(attr.name)) { + el.setAttribute(attr.name, attr.value); + } + } + } + + function commitAcceptedSvelteComponentToDom(sessionId) { + if (!svelteComponentSession || svelteComponentSession.sessionId !== sessionId) return false; + const { wrapperEl, runtime, mountedInstance, manifest } = svelteComponentSession; + const anchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!anchor || !wrapperEl?.parentElement) return false; + const committed = anchor.cloneNode(true); + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(committed, manifest.originalMarkup || ''); + } + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + wrapperEl.parentElement.replaceChild(committed, wrapperEl); + svelteComponentSession = null; + svelteRuntimePromise = null; + selectedElement = committed; + return true; + } + + async function injectSvelteComponentsFromManifest(manifestPath, sessionId) { + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(manifestPath); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + const manifest = JSON.parse(await res.text()); + if (manifest.id !== sessionId) return; + + const paramsByVariant = await loadSvelteComponentParams(manifest); + currentSessionId = sessionId; + expectedVariants = Number(manifest.count) || expectedVariants || 1; + rememberSessionFileMeta({ + sourceFile: manifest.sourceFile, + previewFile: manifestPath, + previewMode: 'svelte-component', + }); + if (state !== 'CYCLING') state = 'GENERATING'; + + const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { + recoveryWaitingForAnchor = false; + svelteComponentSession.paramsByVariant = paramsByVariant; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; + await mountSvelteComponentVariant(visibleVariant || 1); + state = 'CYCLING'; + showOrUpdateCyclingBar(); + saveSession(); + return; + } + + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) { + console.warn('[impeccable] Could not find original element in live DOM.'); + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants + ? visibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + saveSession(); + queueCheckpoint('svelte_component_anchor_missing'); + waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return; + } + + const wrapper = document.createElement('div'); + wrapper.dataset.impeccableVariants = sessionId; + wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1); + wrapper.dataset.impeccablePreview = 'svelte-component'; + wrapper.style.display = 'contents'; + + const mountTarget = document.createElement('div'); + mountTarget.dataset.impeccableComponentMount = sessionId; + mountTarget.style.display = 'contents'; + wrapper.appendChild(mountTarget); + + const insertMode = isSvelteInsertManifest(manifest); + const detachedOriginal = insertMode ? null : liveEl; + if (insertMode) { + removeInsertPlaceholderDom(); + if (manifest.position === 'before') liveEl.parentElement.insertBefore(wrapper, liveEl); + else liveEl.parentElement.insertBefore(wrapper, liveEl.nextSibling); + } else { + liveEl.parentElement.replaceChild(wrapper, liveEl); + } + + svelteComponentSession = { + sessionId, + manifest, + insertMode, + wrapperEl: wrapper, + mountTargetEl: mountTarget, + detachedOriginal, + mountedInstance: null, + mountedVariant: 0, + runtime: null, + propValues: buildSveltePropValuesFromLiveElement(detachedOriginal, manifest), + paramsByVariant, + }; + if (pendingSvelteComponentRetryObserver) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + } + recoveryWaitingForAnchor = false; + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + + const mounted = await mountSvelteComponentVariant(visibleVariant); + if (!mounted) { + // The compiled component threw (e.g. a Svelte compile error in the + // variant file). Don't strand the bar in an empty CYCLING state; restore + // the original element and reset to PICKING so the user can retry. + abortSvelteComponentInjection(sessionId, 'A variant failed to compile. Fix the component and re-run.'); + return; + } + + selectedElement = mountTarget.firstElementChild || mountTarget; + state = 'CYCLING'; + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.'); + } catch (err) { + console.error('[impeccable] Failed to mount Svelte component variants:', err); + abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.'); + } + } + + function waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }) { + if (pendingSvelteComponentRetryObserver) pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = new MutationObserver(() => { + if (svelteComponentSession?.sessionId === sessionId) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + return; + } + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) return; + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + injectSvelteComponentsFromManifest(manifestPath, sessionId); + }); + pendingSvelteComponentRetryObserver.observe(document.body, { childList: true, subtree: true }); + } + + // Reset cleanly when a Svelte component session can't mount: tear the wrapper + // down (restoring the original element), clear persisted session state, and + // return the bar to PICKING. Avoids the stuck 0/0 CYCLING bar. + function abortSvelteComponentInjection(sessionId, message) { + try { + if (svelteComponentSession?.sessionId === sessionId) { + teardownSvelteComponentSession(true); + } else { + const orphan = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (orphan) orphan.remove(); + } + } catch (err) { + console.warn('[impeccable] Svelte component abort cleanup failed:', err); + } + hideShaderOverlay(); + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + stopScrollLock(); + clearSession(); + clearHandled(); + resetSessionFileMeta(); + currentSessionId = null; + expectedVariants = 0; + arrivedVariants = 0; + visibleVariant = 0; + selectedElement = null; + state = 'PICKING'; + hideBar(); + if (message) showToast(message, 5000); } /** @@ -4101,6 +4881,11 @@ * This works even when the dev server caches HTML (Bun, static servers). */ function injectVariantsFromSource(filePath, sessionId) { + if (isSvelteComponentManifestPath(filePath)) { + injectSvelteComponentsFromManifest(filePath, sessionId); + return; + } + rememberSessionFileMeta({ file: filePath }); const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) @@ -4119,7 +4904,7 @@ const doc = parser.parseFromString(block, 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { - console.error('[impeccable] Variant wrapper not found in source file.'); + console.warn('[impeccable] Variant wrapper not found in source file.'); return; } @@ -4134,31 +4919,31 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; - if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - } - + const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); if (!liveEl) { - console.error('[impeccable] Could not find original element in live DOM.'); + console.warn('[impeccable] Could not find original element in live DOM.'); + selectedElement = document.body; + recoveryWaitingForAnchor = true; + state = 'GENERATING'; + showBar('generating'); + saveSession(); + showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } + recoveryWaitingForAnchor = false; // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); arrivedVariants = variants.length; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + recoverEmptyCycling('source-fallback-empty'); + return; + } const saved = loadSession(); const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants @@ -4170,8 +4955,9 @@ selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4184,21 +4970,129 @@ }); } - function cycleVariant(dir) { + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { + const map = new Map(); + if (!sourceOriginal || !liveOriginal) return map; + + const sourceNodes = collectTextNodes(sourceOriginal) + .filter((node) => /\{[^{}]+\}/.test(node.nodeValue || '')); + const liveTexts = collectTextNodes(liveOriginal) + .map((node) => normalizePreviewText(node.nodeValue || '')) + .filter(Boolean); + let liveIndex = 0; + + for (const sourceNode of sourceNodes) { + const sourceText = sourceNode.nodeValue || ''; + const tokens = sourceText.match(/\{[^{}]+\}/g) || []; + if (tokens.length === 0) continue; + + const liveText = liveTexts[liveIndex++] || ''; + if (!liveText) continue; + + if (tokens.length === 1) { + const token = tokens[0]; + const normalizedSource = normalizePreviewText(sourceText); + if (normalizedSource === token) { + map.set(token, liveText); + continue; + } + + const match = liveText.match(expressionTextMatcher(sourceText, [token])); + if (match && match[1]) map.set(token, match[1].trim()); + continue; + } + + if (normalizePreviewText(sourceText) === tokens.join(' ')) { + for (const token of tokens) { + const tokenLiveText = liveTexts[liveIndex - 1] || ''; + if (tokenLiveText) map.set(token, tokenLiveText); + } + } + } + + return map; + } + + function expressionTextMatcher(sourceText, tokens) { + let pattern = '^'; + let cursor = 0; + for (const token of tokens) { + const index = sourceText.indexOf(token, cursor); + if (index === -1) continue; + pattern += escapeRegExp(sourceText.slice(cursor, index)).replace(/\s+/g, '\\s*'); + pattern += '(.*?)'; + cursor = index + token.length; + } + pattern += escapeRegExp(sourceText.slice(cursor)).replace(/\s+/g, '\\s*') + '$'; + return new RegExp(pattern); + } + + function collectTextNodes(root) { + if (!root) return []; + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + nodes.push(node); + node = walker.nextNode(); + } + return nodes; + } + + function normalizePreviewText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + async function selectVariant(next, checkpointReason) { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - const next = visibleVariant + dir; + if (variantSelectionInFlight) return; if (next < 1 || next > arrivedVariants) return; - visibleVariant = next; - showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself - updateSelectedElement(); - updateBarContent('cycling'); - positionBar(); - saveSession(); - queueCheckpoint('variant_changed'); + if (next === visibleVariant) return; + + const previous = visibleVariant; + variantSelectionInFlight = true; + const selectionPromise = (async () => { + visibleVariant = next; + showOrUpdateCyclingBar(); + saveSession(); + const shown = await showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself + if (!shown) { + visibleVariant = previous; + await showVariantInDOM(currentSessionId, previous); + showOrUpdateCyclingBar(); + saveSession(); + return; + } + updateSelectedElement(); + showOrUpdateCyclingBar(); + positionBar(); + saveSession(); + if (checkpointReason) queueCheckpoint(checkpointReason); + })(); + variantSelectionPromise = selectionPromise; + try { + await selectionPromise; + } finally { + if (variantSelectionPromise === selectionPromise) variantSelectionPromise = null; + variantSelectionInFlight = false; + } + } + + function cycleVariant(dir) { + selectVariant(visibleVariant + dir, 'variant_changed'); } function updateSelectedElement() { if (!currentSessionId) return; + if (svelteComponentSession?.sessionId === currentSessionId) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; + return; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); @@ -4206,6 +5100,9 @@ } function readVisibleVariantFromDOM(sessionId) { + if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { + return svelteComponentSession.mountedVariant; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -4341,9 +5238,9 @@ // scrollY that the next resume needs to read. } - // --------------------------------------------------------------------------- + // // MutationObserver for progressive variant reveal - // --------------------------------------------------------------------------- + // function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -4426,10 +5323,11 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); updateSelectedElement(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4445,9 +5343,9 @@ return obs; } - // --------------------------------------------------------------------------- + // // Bar scroll tracking - // --------------------------------------------------------------------------- + // function startScrollTracking() { function tick() { @@ -4483,10 +5381,10 @@ if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; } } - // --------------------------------------------------------------------------- + // // SSE (server→browser) + fetch POST (browser→server) // Zero-dependency replacement for WebSocket. - // --------------------------------------------------------------------------- + // let evtSource = null; let sseRetries = 0; @@ -4509,6 +5407,7 @@ console.log('[impeccable] Live mode connected.'); syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); + restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; syncPageChatFocus('sse-connected'); break; @@ -4531,11 +5430,12 @@ break; case 'done': if (maybeCompleteSteer(msg)) break; + rememberSessionFileMeta(msg); // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); } @@ -4557,7 +5457,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; if (state !== 'GENERATING') return; showToast( - "Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.", + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", 15000, ); }, 2000); @@ -4571,12 +5471,18 @@ // the final complete event. Keep the browser in its recoverable // saving state while the source cleanup is still in flight. break; + case 'discarded': + if (msg.id && msg.id === currentSessionId) { + markSessionHandled(); + cleanup(); + } + break; case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; state = 'CYCLING'; updateBarContent('cycling'); - showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000); + showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; } if (maybeCompleteSteer(msg)) break; @@ -4656,6 +5562,9 @@ expectedVariants, arrivedVariants, visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, paramValues: { ...paramsCurrentValues }, }; } @@ -4665,6 +5574,20 @@ return sendEvent(checkpointPayload(reason)).catch(() => null); } + function sendSteerCheckpoint(id, reason, extra) { + if (!id) return Promise.resolve(null); + return sendEvent({ + type: 'checkpoint', + id, + revision: sessionState.nextCheckpointRevision(), + owner: browserOwner, + phase: 'steer', + reason, + pageUrl: location.pathname, + ...(extra || {}), + }).catch(() => null); + } + function queueCheckpoint(reason) { if (!currentSessionId) return; if (checkpointTimer) clearTimeout(checkpointTimer); @@ -4674,9 +5597,9 @@ }, 120); } - // --------------------------------------------------------------------------- + // // Event handlers - // --------------------------------------------------------------------------- + // function handleMouseMove(e) { if (pendingApplyInFlight) return; @@ -4860,7 +5783,7 @@ // // DISABLED: quick-Go workflows pay an extra harness round trip because // prefetch + generate arrive as two events instead of one. Re-enable with - // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to + // a browser-side debounce (~800-1000ms, cancelled on Go) if we want to // resurrect this. Server validator and skill dispatch remain in place so // flipping this flag is the only change needed. const PREFETCH_ENABLED = false; @@ -4876,6 +5799,14 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + const deepActive = activeElementDeep(); + if ( + deepActive + && own(deepActive) + && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + ) { + return; + } // While a contenteditable text-leaf is focused, let the browser handle // all keys except Escape. Escape cancels the current edit (restores // original text) and blurs without saving, staying in CONFIGURING. @@ -4982,7 +5913,7 @@ if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!selectedElement || state !== 'CONFIGURING') return; stopVoice({ suppressSubmit: true }); - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; // Commit any pending pin edit BEFORE we snapshot annotations. @@ -4996,6 +5927,7 @@ expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); // Flip to GENERATING immediately so the bar morphs without waiting on // capture + upload. The event is emitted from captureAndEmit() once the @@ -5054,7 +5986,7 @@ function handleInsertCreate() { if (!placeholderElement || !insertAnchorElement || state !== 'CONFIGURING' || configureKind !== 'insert') return; - const input = document.getElementById(PREFIX + '-insert-input'); + const input = uiGetById(PREFIX + '-insert-input'); const prompt = input ? input.value.trim() : ''; if (annotEditing) finalizeEditingPin(); const snapshot = { @@ -5064,10 +5996,12 @@ if (!canCreateInsert({ prompt, comments: snapshot.comments, strokes: snapshot.strokes })) return; stopVoice({ suppressSubmit: true }); + pendingAcceptedSession = null; currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); selectedElement = placeholderElement; insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement); @@ -5107,9 +6041,9 @@ captureAndEmit(elForCapture, basePayload, snapshot, captureRect); } - // --------------------------------------------------------------------------- + // // Screenshot capture + upload - // --------------------------------------------------------------------------- + // let msLoadPromise = null; function loadModernScreenshot() { @@ -5120,7 +6054,7 @@ s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; s.onload = () => resolve(window.modernScreenshot); s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; - document.head.appendChild(s); + uiAppendStyle(s); }); return msLoadPromise; } @@ -5235,11 +6169,113 @@ return '#ffffff'; } + function captureChromeNodes() { + const nodes = []; + const add = (node) => { + if (!node || node === document.body || nodes.includes(node)) return; + nodes.push(node); + }; + add(document.getElementById(PREFIX + '-root')); + [ + PREFIX + '-highlight', + PREFIX + '-tooltip', + PREFIX + '-bar', + PREFIX + '-picker', + PREFIX + '-params-panel', + PREFIX + '-insert-line', + PREFIX + '-insert-placeholder', + PREFIX + '-insert-create-tooltip', + PREFIX + '-annot', + PREFIX + '-design-host', + PREFIX + '-toast', + PREFIX + '-shader', + ].forEach((id) => add(uiGetById(id))); + return nodes; + } + + async function hideCaptureChromeForShaderProxy(fn) { + const saved = captureChromeNodes().map((node) => ({ + node, + visibility: node.style.visibility, + priority: node.style.getPropertyPriority('visibility'), + })); + for (const { node } of saved) { + node.style.setProperty('visibility', 'hidden', 'important'); + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + try { + return await fn(); + } finally { + for (const { node, visibility, priority } of saved) { + node.style.setProperty('visibility', visibility, priority); + } + } + } + + function shouldUseAncestorCropShaderProxy(el) { + // TODO: Enable this proxy for React/Vue/etc. adapters once their live + // preview mounts are covered by the same shader regression checks. + const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase(); + if (adapter === 'svelte' || adapter === 'sveltekit') return true; + if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true; + const wrapper = el?.closest?.('[data-impeccable-variants]'); + return wrapper?.dataset?.impeccablePreview === 'svelte-component'; + } + + function paintsShaderProxySurface(node) { + const s = getComputedStyle(node); + return !isTransparentColor(s.backgroundColor) + || (s.backgroundImage && s.backgroundImage !== 'none') + || paintsBackdrop(node); + } + + function findShaderProxyCaptureRoot(el) { + const doc = el.ownerDocument || document; + const er = el.getBoundingClientRect(); + let node = el.parentElement; + while (node && node !== doc.documentElement) { + const nr = node.getBoundingClientRect(); + const containsElement = + nr.width > 0 && nr.height > 0 && + nr.left <= er.left + 0.5 && + nr.top <= er.top + 0.5 && + nr.right >= er.right - 0.5 && + nr.bottom >= er.bottom - 0.5; + if (containsElement && paintsShaderProxySurface(node)) return node; + node = node.parentElement; + } + return null; + } + // Capture the element (with current annotations baked in) and return // { blob, paper }: the PNG Blob, plus the representative backdrop tone for the // shader's halftone ground (so capture, upload, and shader all agree on what // sits behind the element). Shared between the Go flow (uploads the blob) and // the shader-resume path. + async function captureElementFromRenderedAncestor(ms, el, opts) { + const doc = el.ownerDocument || document; + const captureRoot = findShaderProxyCaptureRoot(el); + if (!captureRoot) throw new Error('No painted ancestor for Svelte shader proxy'); + const rootCanvas = await ms.domToCanvas(captureRoot, opts); + const S = opts.scale; + const er = el.getBoundingClientRect(); + const rr = captureRoot.getBoundingClientRect(); + const sx = (er.left - rr.left) * S; + const sy = (er.top - rr.top) * S; + const sw = er.width * S; + const sh = er.height * S; + if (sw <= 0 || sh <= 0) throw new Error('Selected element has no visible capture rect'); + const crop = doc.createElement('canvas'); + crop.width = Math.max(1, Math.round(sw)); + crop.height = Math.max(1, Math.round(sh)); + const cctx = crop.getContext('2d', { willReadFrequently: true }); + cctx.drawImage(rootCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height); + const paper = dominantRgb01(cctx, crop.width, crop.height) || averageRgb01(cctx, crop.width, crop.height); + const blob = await new Promise((res) => crop.toBlob(res, 'image/png')); + if (!blob) throw new Error('Ancestor crop failed to produce a PNG blob'); + return { blob, paper }; + } + async function captureElementToBlob(el, snapshot, rect) { try { if (document.fonts?.ready) await document.fonts.ready; } catch {} const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); @@ -5261,6 +6297,13 @@ scale: Math.min(window.devicePixelRatio || 1, 2), font: fontCssText ? { cssText: fontCssText } : undefined, }; + if (shouldUseAncestorCropShaderProxy(el)) { + try { + return await hideCaptureChromeForShaderProxy(() => captureElementFromRenderedAncestor(ms, el, opts)); + } catch (err) { + console.warn('[impeccable] Svelte ancestor crop capture failed, falling back to element capture:', err); + } + } const bg = resolveCanvasBackground(el); // Fast path: the element paints its own background, or an opaque ancestor // color was found. modern-screenshot bakes that color; paper matches it. @@ -5342,13 +6385,13 @@ sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); } - // --------------------------------------------------------------------------- + // // Shader overlay - renders the captured screenshot as a WebGL texture and // runs an editorial "ink-wash" fragment shader over it during generation. // A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku // and leaving a soft trail. Makes the wait feel like a letterpress scan // instead of a dead spinner. - // --------------------------------------------------------------------------- + // const SHADER_VS = `attribute vec2 a_position; attribute vec2 a_uv; @@ -5502,6 +6545,31 @@ void main() { return n ? [r / n / 255, g / n / 255, b / n / 255] : SHADER_PAPER_FALLBACK; } + // Pick the most common visible color cluster from a crop. A straight average + // gets pulled by text and icons; the dominant bucket usually represents the + // surface the shader should dissolve into. + function dominantRgb01(ctx, w, h) { + const data = ctx.getImageData(0, 0, w, h).data; + const stride = Math.max(1, Math.floor((w * h) / 6000)); + const buckets = new Map(); + for (let p = 0; p < w * h; p += stride) { + const i = p * 4; + if (data[i + 3] < 16) continue; + const key = (data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4); + const bucket = buckets.get(key) || { count: 0, r: 0, g: 0, b: 0 }; + bucket.count += 1; + bucket.r += data[i]; + bucket.g += data[i + 1]; + bucket.b += data[i + 2]; + buckets.set(key, bucket); + } + let best = null; + for (const bucket of buckets.values()) { + if (!best || bucket.count > best.count) best = bucket; + } + return best ? [best.r / best.count / 255, best.g / best.count / 255, best.b / best.count / 255] : null; + } + // Average the backdrop sampled just OUTSIDE an element's rect within a larger // canvas. The ground tone for the dissolve must be the real backdrop, not the // mean of the element's own crop - averaging the crop folds in the element's @@ -5572,7 +6640,7 @@ void main() { fallback.style.backgroundRepeat = 'no-repeat'; fallback.style.outline = '2px dashed ' + C.brand; fallback.style.outlineOffset = '-2px'; - document.body.appendChild(fallback); + uiAppend(fallback); shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl }; } @@ -5582,16 +6650,19 @@ void main() { const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); + const radius = getComputedStyle(el).borderRadius; canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); Object.assign(canvas.style, { position: 'fixed', top: rect.top + 'px', left: rect.left + 'px', width: rect.width + 'px', height: rect.height + 'px', + borderRadius: radius, + overflow: 'hidden', pointerEvents: 'none', zIndex: Z.bar - 1, }); - document.body.appendChild(canvas); + uiAppend(canvas); const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) || canvas.getContext('experimental-webgl'); @@ -5685,8 +6756,12 @@ void main() { frame(); } - function handleAccept() { + async function handleAccept() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } + if (pendingAcceptedSession || state === 'SAVING') return; + if (variantSelectionPromise) { + try { await variantSelectionPromise; } catch { /* failed selection falls back below */ } + } if (!currentSessionId || arrivedVariants === 0) return; const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId); if (domVisibleVariant > 0) visibleVariant = domVisibleVariant; @@ -5696,30 +6771,39 @@ void main() { variantId: String(visibleVariant), pageUrl: location.pathname, }; + const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } + // The accepted variant is already the only visible child of the wrapper + // (all other variants are display:none). HMR from the source rewrite will + // replace the wrapper imminently. Don't eagerly replaceChild here - React + // reconciliation races with our mutation and throws NotFoundError in Next + // 16 / Turbopack. Schedule a fallback that runs the manual swap only if + // HMR hasn't cleaned up by then (keeps static-server flows working). const acceptedSessionId = currentSessionId; const acceptedVariant = visibleVariant; + const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId + || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - pendingAcceptedSession = { - id: acceptedSessionId, - variant: String(acceptedVariant), - ...acceptedSnapshot, - finalizing: false, - }; state = 'SAVING'; updateBarContent('saving'); + pendingAcceptedSession = { + id: acceptedSessionId, + variant: String(acceptedVariant), + isSvelteComponent: acceptedIsSvelteComponent, + ...acceptedSnapshot, + finalizing: false, + }; + saveSession(); sendEvent(acceptPayload, { throwOnError: true }) - .then(() => { - markSessionHandled(); - }) + .then(() => {}) .catch(() => { - pendingAcceptedSession = null; + if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); } @@ -5733,19 +6817,21 @@ void main() { } if (pending.finalizing) return true; pending.finalizing = true; - + markSessionHandled(); + if (pending.isSvelteComponent) { + commitAcceptedSvelteComponentToDom(pending.id); + } state = 'CONFIRMED'; updateBarContent('confirmed'); + scheduleAcceptCleanup(pending); + return true; + } - // Give framework HMR a short chance to render the now-clean accepted - // source. If it misses the update, unwrap the accepted variant after the - // source-side completion event so the page is not left empty or stale. + function scheduleAcceptCleanup(accepted) { setTimeout(function() { - ensureAcceptedDomClean(pending); + if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted); cleanupAcceptedSession(); }, 1200); - - return true; } function snapshotAcceptedVariantDom(sessionId, variantId) { @@ -5833,6 +6919,7 @@ void main() { stopScrollLock(); clearScrollY(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5841,6 +6928,28 @@ void main() { state = 'PICKING'; } + function commitAcceptedVariantToDom(sessionId, variantId) { + const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapper) return false; + const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); + if (!accepted || !accepted.firstElementChild) return false; + const parent = wrapper.parentElement; + if (!parent) return false; + + const style = wrapper.querySelector('style[data-impeccable-css]'); + if (style && !document.querySelector('style[data-impeccable-accepted-css="' + sessionId + '"]')) { + const promotedStyle = style.cloneNode(true); + promotedStyle.setAttribute('data-impeccable-accepted-css', sessionId); + parent.insertBefore(promotedStyle, wrapper); + } + + const committed = accepted.cloneNode(true); + committed.removeAttribute('hidden'); + committed.style.display = 'contents'; + parent.replaceChild(committed, wrapper); + return true; + } + function handleDiscard() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!currentSessionId) return; @@ -5852,11 +6961,141 @@ void main() { .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000)); } - // --------------------------------------------------------------------------- + // // Session persistence via live-browser-session.js - // --------------------------------------------------------------------------- + // // Survives page reloads, browser close/reopen, HMR, and accidental refreshes. + function normalizeSessionPath(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed.replace(/\\/g, '/') : null; + } + + function resetSessionFileMeta() { + currentSourceFile = null; + currentPreviewFile = null; + currentPreviewMode = null; + recoveryWaitingForAnchor = false; + } + + function rememberSessionFileMeta(meta = {}) { + const file = normalizeSessionPath(meta.file); + const sourceFile = normalizeSessionPath(meta.sourceFile); + const previewFile = normalizeSessionPath(meta.previewFile); + const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null); + + if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) { + currentPreviewMode = 'svelte-component'; + currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile); + currentSourceFile = sourceFile || currentSourceFile; + return; + } + + if (sourceFile || file) currentSourceFile = sourceFile || file; + if (previewFile) currentPreviewFile = previewFile; + if (previewMode) currentPreviewMode = previewMode; + } + + function applySavedSessionMeta(saved) { + if (!saved) return; + rememberSessionFileMeta(saved); + if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.action) selectedAction = saved.action; + if (saved.count) selectedCount = saved.count; + if (saved.previewMode) currentPreviewMode = saved.previewMode; + if (saved.paramValues && typeof saved.paramValues === 'object') { + paramsCurrentValues = { ...saved.paramValues }; + } + } + + function normalizePagePath(value) { + if (!value || typeof value !== 'string') return null; + try { + return new URL(value, location.origin).pathname; + } catch { + return value.split(/[?#]/)[0] || null; + } + } + + function pageMatchesCurrent(value) { + const path = normalizePagePath(value); + return !path || path === location.pathname; + } + + function isTerminalSessionSummary(session) { + return /^(completed|discarded|discard_requested|accept_requested)$/.test(String(session?.phase || '')); + } + + function findActiveSessionSummary(saved, activeSessions) { + if (!saved?.id || !Array.isArray(activeSessions)) return null; + return activeSessions.find((session) => + session?.id === saved.id + && pageMatchesCurrent(session.pageUrl || saved.pageUrl) + && !isTerminalSessionSummary(session) + ) || null; + } + + function clampVariantIndex(value, count) { + const num = Number(value); + const max = Number(count); + if (!Number.isFinite(num) || num < 1) return 0; + if (Number.isFinite(max) && max > 0 && num > max) return 0; + return Math.floor(num); + } + + function restoreSessionWithoutWrapper(reason, activeSessions) { + const saved = loadSession(); + if (!saved?.id || isSessionHandled(saved.id)) return false; + const savedState = String(saved.state || '').toUpperCase(); + if (savedState !== 'GENERATING' && savedState !== 'CYCLING') return false; + + const serverSession = findActiveSessionSummary(saved, activeSessions); + if (Array.isArray(activeSessions) && activeSessions.length > 0 && !serverSession) { + return false; + } + + currentSessionId = saved.id; + applySavedSessionMeta(serverSession); + applySavedSessionMeta(saved); + + expectedVariants = Number(saved.expected || serverSession?.expectedVariants || selectedCount || 0); + arrivedVariants = Number(saved.arrived || serverSession?.arrivedVariants || 0); + if (arrivedVariants <= 0 && currentPreviewFile) arrivedVariants = Number(serverSession?.expectedVariants || saved.expected || selectedCount || 0); + if (expectedVariants <= 0) expectedVariants = Number(serverSession?.expectedVariants || arrivedVariants || selectedCount || 0); + visibleVariant = clampVariantIndex(saved.visible, arrivedVariants || expectedVariants) + || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) + || (arrivedVariants > 0 ? 1 : 0); + + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + if (variantObserver) variantObserver.disconnect(); + variantObserver = startVariantObserver(currentSessionId); + saveSession(); + queueCheckpoint(reason || 'browser_restore_without_wrapper'); + + const restoreFile = currentPreviewMode === 'svelte-component' + ? currentPreviewFile + : (currentSourceFile || currentPreviewFile); + if (restoreFile) { + injectVariantsFromSource(restoreFile, currentSessionId); + return true; + } + + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return true; + } + + function restoreFromActiveSessions(activeSessions, reason) { + const wrapper = document.querySelector('[data-impeccable-variants]'); + if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false; + if (svelteComponentSession?.sessionId === currentSessionId) return false; + return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); + } + function saveSession() { if (!currentSessionId) return; // NOTE: scrollY is stored under a separate key (writeScrollY). Storing @@ -5869,6 +7108,11 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, + pageUrl: location.pathname, + paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, }); } @@ -5898,31 +7142,33 @@ void main() { } function cleanup() { - // Hide the wrapper immediately so variants disappear. DON'T structurally - // mutate the DOM yet - HMR from the agent's source rewrite is on its way, - // and a manual replaceChild under React causes NotFoundError when the - // reconciler later tries to remove a wrapper we already removed. - // Schedule a 2s fallback that does the manual swap only if HMR hasn't - // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const cleanupSessionId = currentSessionId; - if (cleanupSessionId) { + if (svelteComponentSession?.sessionId === cleanupSessionId) { + teardownSvelteComponentSession(true); + } else if (cleanupSessionId) { + // Hide the wrapper immediately so variants disappear. DON'T structurally + // mutate the DOM yet - HMR from the agent's source rewrite is on its way, + // and a manual replaceChild under React causes NotFoundError when the + // reconciler later tries to remove a wrapper we already removed. + // Schedule a 2s fallback that does the manual swap only if HMR hasn't + // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); if (wrapper) wrapper.style.display = 'none'; - } - setTimeout(function() { - if (!cleanupSessionId) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!wrapper) return; - const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - wrapper.parentElement.replaceChild(content, wrapper); - return; + setTimeout(function() { + if (!cleanupSessionId) return; + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) return; + const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); + if (orig) { + const content = orig.firstElementChild; + if (content) { + lateWrapper.parentElement.replaceChild(content, lateWrapper); + return; + } } - } - wrapper.remove(); - }, 2000); + lateWrapper.remove(); + }, 2000); + } hideBar(); hideHighlight(); stopScrollTracking(); @@ -5931,6 +7177,7 @@ void main() { clearScrollY(); finalizeInsertSession(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5938,9 +7185,9 @@ void main() { state = 'PICKING'; } - // --------------------------------------------------------------------------- + // // Toast - // --------------------------------------------------------------------------- + // function showToast(message, duration) { if (toastEl) toastEl.remove(); @@ -5964,7 +7211,7 @@ void main() { }); toastEl.id = PREFIX + '-toast'; toastEl.textContent = message; - document.body.appendChild(toastEl); + uiAppend(toastEl); requestAnimationFrame(() => { toastEl.style.opacity = '1'; toastEl.style.transform = 'translateX(-50%) translateY(0)'; @@ -5978,22 +7225,70 @@ void main() { }, duration); } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. function resumeSession() { const wrapper = document.querySelector('[data-impeccable-variants]'); - if (!wrapper) { clearSession(); clearHandled(); return false; } + if (!wrapper) { + if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } const sessionId = wrapper.dataset.impeccableVariants; // Don't resume if this session was already accepted/discarded if (isSessionHandled(sessionId)) return false; + // Svelte component sessions can't be resumed by counting DOM children: the + // wrapper holds a single mount target, not [data-impeccable-variant] nodes, + // and a page reload unmounts every compiled variant. Counting children here + // would strand the bar in CYCLING at 0/0. If there's no live in-memory mount + // for this wrapper, it's an orphan (reload / failed mount): drop it and let + // the live-server's SSE re-inject the manifest if the session is still live. + if (wrapper.dataset.impeccablePreview === 'svelte-component' + && svelteComponentSession?.sessionId !== sessionId) { + wrapper.remove(); + if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } + + if (wrapper.dataset.impeccablePreview === 'svelte-component') { + if (!svelteComponentSession?.mountedVariant) { + return true; + } + currentSessionId = sessionId; + expectedVariants = Number(wrapper.dataset.impeccableVariantCount) + || Number(svelteComponentSession.manifest?.count) + || expectedVariants + || 1; + arrivedVariants = expectedVariants; + const saved = loadSession(); + applySavedSessionMeta(saved); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = svelteComponentSession.mountedVariant > 0 && svelteComponentSession.mountedVariant <= arrivedVariants + ? svelteComponentSession.mountedVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = resolveSvelteComponentAnchor() + || wrapper.parentElement; + state = 'CYCLING'; + hideShaderOverlay(); + showBar('cycling'); + startScrollTracking(); + refreshParamsPanel(); + saveSession(); + queueCheckpoint('browser_resumed_svelte_component'); + return true; + } + currentSessionId = sessionId; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -6002,6 +7297,7 @@ void main() { // Restore state from localStorage if available const saved = loadSession(); if (saved && saved.id === sessionId) { + applySavedSessionMeta(saved); visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0); if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; @@ -6072,9 +7368,9 @@ void main() { return true; } - // --------------------------------------------------------------------------- + // // Global bar (always visible at bottom) - // --------------------------------------------------------------------------- + // let globalBarEl = null; let globalBarBrandEl = null; @@ -6166,6 +7462,8 @@ void main() { let pageChatExpanded = false; let steerLocked = false; let steerRequestId = null; + let steerPendingMessage = ''; + let steerInputWasFocused = false; let pageChatDotsEl = null; let steerAwaitTimer = null; let voiceRecognition = null; @@ -6179,7 +7477,7 @@ void main() { const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; - const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect'; + const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect'; const GLOBAL_BAR_SECTION_GAP = 8; const GLOBAL_BAR_INNER_GAP = 2; const GLOBAL_BAR_INNER_PAD_LEFT = 2; @@ -6323,7 +7621,7 @@ void main() { const attempt = () => { steerFocusRecoverTimer = null; if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (pageHasHostTextSelection()) { steerFocusRecoverTimer = setTimeout(attempt, 120); return; @@ -6344,7 +7642,7 @@ void main() { steerFocusSuspended = true; steerFocusPauseUntil = performance.now() + STEER_PAGE_FOCUS_PAUSE_MS; pagePointerGesture = { x: e.clientX, y: e.clientY, dragged: false }; - if (pageChatInput && document.activeElement === pageChatInput) { + if (pageChatInput && activeElementDeep() === pageChatInput) { pageChatInput.blur(); } } @@ -6404,7 +7702,7 @@ void main() { pickActive, pageChatReady: !!pageChatInput, pageChatExpanded, - active: steerFocusTargetLabel(document.activeElement), + active: steerFocusTargetLabel(activeElementDeep()), shouldSteer: shouldFocusSteerChat(), ...(extra || {}), }); @@ -6423,26 +7721,26 @@ void main() { function focusConfigureInput(reason) { steerFocusLog('focusConfigureInput', { reason }); const inputId = configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input'; - const input = document.getElementById(inputId); + const input = uiGetById(inputId); if (!input) { steerFocusLog('focusConfigureInput missing', { reason }); return; } setTimeout(() => { - const before = document.activeElement; + const before = activeElementDeep(); input.focus(); steerFocusLog('focusConfigureInput result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== input, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== input, }); }, 60); } function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; - const focused = document.activeElement === pageChatInput; + const focused = activeElementDeep() === pageChatInput; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; const P = pageChatPalette(); pageChatEl.style.borderColor = steerLocked @@ -6476,15 +7774,15 @@ void main() { } syncPageChatVisual(); pageChatInput.style.pointerEvents = 'auto'; - const before = document.activeElement; + const before = activeElementDeep(); try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== pageChatInput, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== pageChatInput, }); } @@ -6515,6 +7813,37 @@ void main() { return wrap; } + function keepSteerPointerInside(e, opts = {}) { + e.stopPropagation(); + if (opts.preventDefault !== false) e.preventDefault(); + } + + function preparePageChatInputForTyping() { + if (!pageChatEl || !pageChatInput) return false; + pageChatExpanded = true; + pageChatEl.dataset.expanded = 'true'; + pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; + pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return true; + } + + function focusPageChatInput(reason) { + if (!preparePageChatInputForTyping() || steerLocked) return false; + try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } + const focused = activeElementDeep() === pageChatInput; + if (focused) steerInputWasFocused = true; + syncPageChatFocusRing(); + return focused; + } + function clearSteerAwaitTimer() { if (steerAwaitTimer) { clearTimeout(steerAwaitTimer); @@ -6528,6 +7857,7 @@ void main() { if (!steerLocked || steerRequestId !== id) return; unlockSteerChat({ error: 'Steer timed out waiting for the agent. Check that live-poll is running and replies with steer_done.', + restoreMessage: steerPendingMessage, }); }, STEER_AWAIT_TIMEOUT_MS); } @@ -6538,19 +7868,12 @@ void main() { steerLocked = true; pageChatEl.dataset.processing = 'true'; pageChatInput.disabled = true; - pageChatInput.value = ''; - pageChatInput.blur(); + preparePageChatInputForTyping(); if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = true; pageChatVoiceBtn.style.display = 'none'; } - pageChatExpanded = false; - pageChatEl.dataset.expanded = 'false'; - pageChatEl.style.width = PAGE_CHAT_PROCESSING_W; pageChatEl.style.cursor = 'default'; - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; pageChatInput.style.pointerEvents = 'none'; if (pageChatHint) { pageChatHint.style.display = 'none'; @@ -6568,17 +7891,26 @@ void main() { function unlockSteerChat(opts) { clearSteerAwaitTimer(); + const restoreMessage = typeof opts?.restoreMessage === 'string' ? opts.restoreMessage : ''; + const keepExpanded = Boolean(opts?.error && restoreMessage); steerLocked = false; + const completedId = steerRequestId; steerRequestId = null; if (!pageChatEl) return; pageChatEl.dataset.processing = 'false'; pageChatEl.removeAttribute('aria-busy'); pageChatEl.setAttribute('aria-label', 'Steer the page'); - pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W; + pageChatExpanded = keepExpanded; + pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false'; + pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W; pageChatEl.style.cursor = 'pointer'; if (pageChatInput) { pageChatInput.disabled = false; - pageChatInput.value = ''; + pageChatInput.value = keepExpanded ? restoreMessage : ''; + pageChatInput.style.width = keepExpanded ? '' : '0'; + pageChatInput.style.padding = keepExpanded ? '0 6px' : '0'; + pageChatInput.style.opacity = keepExpanded ? '1' : '0'; + pageChatInput.style.pointerEvents = 'auto'; } if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = false; @@ -6586,18 +7918,28 @@ void main() { } if (pageChatHint) { pageChatHint.textContent = 'Steer'; - pageChatHint.style.display = ''; - pageChatHint.style.visibility = ''; + pageChatHint.style.display = keepExpanded ? 'none' : ''; + pageChatHint.style.visibility = keepExpanded ? 'hidden' : ''; + pageChatHint.style.opacity = keepExpanded ? '0' : '1'; } if (pageChatDotsEl?.parentNode) { pageChatDotsEl.remove(); pageChatDotsEl = null; } + steerPendingMessage = keepExpanded ? restoreMessage : ''; + steerInputWasFocused = false; syncPageChatChrome(); syncPageChatFocusRing(); if (opts?.error) showToast(String(opts.error), 5000); else if (opts?.message) showToast(String(opts.message), 4000); - syncPageChatFocus('steer-unlock'); + if (completedId) { + sendSteerCheckpoint(completedId, opts?.error ? 'steer_error' : 'steer_done', { + message: opts?.message || opts?.error || '', + file: opts?.file || '', + }); + } + if (keepExpanded) focusPageChatInput('steer-error-restore'); + else syncPageChatFocus('steer-unlock'); } function steerSpeechRecognitionCtor() { @@ -6651,7 +7993,7 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = document.getElementById(PREFIX + '-configure-voice'); + const voiceBtn = uiGetById(PREFIX + '-configure-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -6784,7 +8126,7 @@ void main() { } function configureVoiceContext() { - const input = document.getElementById( + const input = uiGetById( configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input', ); return { @@ -6819,26 +8161,37 @@ void main() { if (!text || steerLocked) return; const id = id8(); steerRequestId = id; + steerPendingMessage = text; + if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); lockSteerChat(); scheduleSteerAwaitTimeout(id); + sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); sendEvent({ type: 'steer', id, message: text, pageUrl: location.href, }).then((res) => { - if (!res) unlockSteerChat({ error: 'Could not reach live server' }); + if (!res) { + sendSteerCheckpoint(id, 'steer_send_failed', { message: text }); + unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text }); + } }); } function maybeCompleteSteer(msg) { if (!steerRequestId || msg.id !== steerRequestId) return false; if (msg.type === 'steer_done') { - unlockSteerChat({ message: msg.message }); + unlockSteerChat({ message: msg.message, file: msg.file }); + if (msg.file && /\.svelte(?:$|\?)/.test(String(msg.file))) { + setTimeout(() => { + if (!steerLocked) showToast('Steer applied. Reload if the page has not refreshed yet.', 5000); + }, 4500); + } return true; } if (msg.type === 'error') { - unlockSteerChat({ error: msg.message || 'Steer failed' }); + unlockSteerChat({ error: msg.message || 'Steer failed', restoreMessage: steerPendingMessage }); return true; } return false; @@ -6847,21 +8200,10 @@ void main() { function expandPageChat(opts) { const focus = !opts || opts.focus !== false; if (!pageChatEl || !pageChatInput || steerLocked) return; - pageChatExpanded = true; - pageChatEl.dataset.expanded = 'true'; - pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; - pageChatEl.style.cursor = 'text'; - if (pageChatHint) { - pageChatHint.style.display = 'none'; - pageChatHint.style.opacity = '0'; - } - pageChatInput.style.width = ''; - pageChatInput.style.padding = '0 6px'; - pageChatInput.style.opacity = '1'; - pageChatInput.style.pointerEvents = 'auto'; + preparePageChatInputForTyping(); syncPageChatChrome(); syncPageChatFocusRing(); - if (focus) pageChatInput.focus(); + if (focus) focusPageChatInput('expand-page-chat'); } function collapsePageChat(opts) { @@ -6878,7 +8220,7 @@ void main() { } else { pageChatInput.style.pointerEvents = 'auto'; } - if (pageChatHint && document.activeElement !== pageChatInput) { + if (pageChatHint && activeElementDeep() !== pageChatInput) { pageChatHint.style.display = ''; pageChatHint.style.opacity = '1'; } @@ -6952,7 +8294,7 @@ void main() { pageChatEl.appendChild(pageChatInput); pageChatEl.appendChild(pageChatVoiceBtn); - if (!document.getElementById(PREFIX + '-page-chat-style')) { + if (!uiGetById(PREFIX + '-page-chat-style')) { const s = document.createElement('style'); s.id = PREFIX + '-page-chat-style'; s.textContent = @@ -6966,23 +8308,34 @@ void main() { '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } - pageChatEl.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { + keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat(); + expandPageChat({ focus: false }); + focusPageChatInput('page-chat-click'); }); - pageChatVoiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); + pageChatVoiceBtn.addEventListener('mousedown', keepSteerPointerInside); pageChatVoiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); + keepSteerPointerInside(e); if (steerLocked) return; toggleSteerVoice(); }); + pageChatInput.addEventListener('pointerdown', keepSteerPointerInside); + pageChatInput.addEventListener('mousedown', keepSteerPointerInside); + pageChatInput.addEventListener('click', (e) => { + keepSteerPointerInside(e); + if (!steerLocked) focusPageChatInput('page-chat-input-click'); + }); + pageChatInput.addEventListener('input', () => { syncPageChatVisual(); }); @@ -6995,7 +8348,7 @@ void main() { syncPageChatFocusRing(); setTimeout(() => { if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (!pageChatInput.value.trim()) collapsePageChat(); scheduleSteerFocusRecover('steer-blur-recover'); }, 120); @@ -7039,7 +8392,7 @@ void main() { globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false'; globalBarBrandEl.setAttribute('aria-label', connected ? 'Impeccable live mode' - : 'Impeccable live mode: agent not polling'); + : 'Impeccable live mode - agent not polling'); globalBarBrandEl.removeAttribute('title'); globalBarBrandEl.style.cursor = connected ? 'default' : 'help'; const mark = globalBarBrandEl.querySelector('[data-brand-mark]'); @@ -7077,7 +8430,7 @@ void main() { }); agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip'; agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP; - document.body.appendChild(agentPollTooltipEl); + uiAppend(agentPollTooltipEl); return agentPollTooltipEl; } @@ -7131,7 +8484,7 @@ void main() { // Custom focus-visible for bar buttons. Browser default is a heavy // blue ring that looks jarring on the dark capsule. Replace with a // soft accent-tinted inner ring that respects the bar's palette. - if (!document.getElementById(PREFIX + '-bar-focus-style')) { + if (!uiGetById(PREFIX + '-bar-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-bar-focus-style'; s.textContent = @@ -7143,7 +8496,7 @@ void main() { '@keyframes impeccable-agent-dot { 0%, 100% { opacity: 0.45; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1); } }' + '#' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: impeccable-agent-dot 1.4s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: none; opacity: 0.9; } }'; - document.head.appendChild(s); + uiAppendStyle(s); } globalBarEl = el('div', { @@ -7176,7 +8529,7 @@ void main() { brand.id = PREFIX + '-global-bar-brand'; brand.dataset.agentConnected = 'false'; brand.setAttribute('role', 'img'); - brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling'); + brand.setAttribute('aria-label', 'Impeccable live mode - agent not polling'); const brandMark = el('span', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -7211,7 +8564,7 @@ void main() { inner.id = PREFIX + '-global-bar-inner'; globalBarEl.appendChild(inner); - // --- button factory: icon-only at rest, label slides in on hover/active --- + // Button factory: icon-only at rest, label slides in on hover/active. function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { const b = el('button', { position: 'relative', @@ -7506,6 +8859,7 @@ void main() { color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', }); + exitBtn.id = PREFIX + '-exit'; exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = 'oklch(58% 0.15 35)'; exitBtn.style.background = P.exitHover; }); @@ -7530,8 +8884,8 @@ void main() { try { window.focus(); } catch { /* in-app preview may block */ } }, true); - document.body.appendChild(pendingDockEl); - document.body.appendChild(globalBarEl); + uiAppend(pendingDockEl); + uiAppend(globalBarEl); defangOutsideHandlers(pendingDockEl); defangOutsideHandlers(globalBarEl); @@ -7553,11 +8907,11 @@ void main() { } function updateGlobalBarState() { - const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); - const detectBadge = document.getElementById(PREFIX + '-detect-badge'); - const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); - const insertToggle = document.getElementById(PREFIX + '-insert-toggle'); - const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const detectToggle = uiGetById(PREFIX + '-detect-toggle'); + const detectBadge = uiGetById(PREFIX + '-detect-badge'); + const pickToggle = uiGetById(PREFIX + '-pick-toggle'); + const insertToggle = uiGetById(PREFIX + '-insert-toggle'); + const designToggle = uiGetById(PREFIX + '-design-toggle'); const theme = globalBarEl?.dataset.theme || 'light'; const P = barPaletteForTheme(theme); @@ -7751,8 +9105,9 @@ void main() { pendingApplyInFlight = false; } if (globalBarEl) { - globalBarEl.style.transform = 'translateY(100%)'; - setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300); + globalBarEl.style.transition = 'none'; + globalBarEl.remove(); + globalBarEl = null; } pageChatEl = null; pageChatInput = null; @@ -7765,6 +9120,7 @@ void main() { if (barEl) { barEl.remove(); barEl = null; } if (pickerEl) { pickerEl.remove(); pickerEl = null; } if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; } + if (editBadgeProxyRoot) { editBadgeProxyRoot.remove(); editBadgeProxyRoot = null; editBadgeProxyByTarget = new Map(); } if (evtSource) { evtSource.close(); evtSource = null; } document.removeEventListener('mousemove', handleMouseMove, true); document.removeEventListener('click', handleClick, true); @@ -7777,9 +9133,9 @@ void main() { console.log('[impeccable] Live mode exited.'); } - // --------------------------------------------------------------------------- + // // Design System Panel - visualizes the project's .impeccable/design.json sidecar - // --------------------------------------------------------------------------- + // const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; const DESIGN_PANEL_WIDTH = 440; @@ -7847,7 +9203,7 @@ void main() { root.className = 'root'; designShadow.appendChild(root); - document.body.appendChild(designHost); + uiAppend(designHost); // The host is pointer-events: none; the panel inside the shadow DOM // manages its own auto/none. Events bubble through the shadow boundary, // so attaching here silences host-page outside-interaction handlers @@ -7889,7 +9245,7 @@ void main() { .root * { box-sizing: border-box; } button { font: inherit; color: inherit; } - /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + /* Panel shell: chrome matches the bar; body canvas stays neutral */ .panel { position: fixed; top: 12px; bottom: 72px; right: 12px; width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); @@ -7955,7 +9311,7 @@ void main() { .panel-body::-webkit-scrollbar { width: 8px; } .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } - /* --- States --- */ + /* States */ .empty, .loading, .error { margin: 16px 4px; padding: 28px 20px; text-align: center; @@ -7966,7 +9322,7 @@ void main() { .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; } .error { color: oklch(45% 0.15 25); } - /* --- Stale hint --- */ + /* Stale hint */ .stale { display: flex; align-items: center; gap: 8px; margin: 8px 4px 12px; @@ -7979,7 +9335,7 @@ void main() { .stale-text { flex: 1; min-width: 0; } .stale-text strong { color: ${DP.ink}; font-weight: 600; } - /* --- Parsed-md fallback banner --- */ + /* Parsed-md fallback banner */ .parsed-md-cta { margin: 8px 4px 14px; padding: 14px 16px; @@ -7991,7 +9347,7 @@ void main() { .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; } .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; } - /* --- Tile primitives --- */ + /* Tile primitives */ .tile { position: relative; background: ${DP.tile}; @@ -8010,7 +9366,7 @@ void main() { } .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; } - /* --- Color tile --- */ + /* Color tile */ .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; } .c-tile:hover { transform: translateY(-1px); } .c-hero { @@ -8025,7 +9381,7 @@ void main() { .c-ramp > span { flex: 1; } .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Type tile --- */ + /* Type tile */ .t-tile { } .t-specimen { margin: 4px 0 6px; @@ -8035,7 +9391,7 @@ void main() { .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; } .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Shadow tile --- */ + /* Shadow tile */ .s-tile { } .s-surface { height: 60px; margin: 8px 2px 10px; @@ -8045,14 +9401,14 @@ void main() { .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; } .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; } - /* --- Radii strip --- */ + /* Radii strip */ .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; } .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; } .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); } .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; } .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; } - /* --- Component tile (hosts live primitives) --- */ + /* Component tile (hosts live primitives) */ .cmp-tile { } .cmp-stage { margin: 12px -4px 0; @@ -8066,7 +9422,7 @@ void main() { .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; } .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; } - /* --- Collapsible --- */ + /* Collapsible */ .coll { margin: 0 4px 8px; background: ${DP.tile}; @@ -8131,7 +9487,7 @@ void main() { .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; } .coll .overview-body li { margin-bottom: 3px; } - /* --- raw tab markdown (unchanged layout, neutralized palette) --- */ + /* raw tab markdown (unchanged layout, neutralized palette) */ .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; } .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; } .md h1 { font-size: 18px; } @@ -8303,7 +9659,7 @@ void main() { return box; } - // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 --- + // Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 function renderDesignVisual(body, parsed, sidecar) { const frontmatter = parsed?.frontmatter || {}; @@ -8667,7 +10023,7 @@ void main() { return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components'); } - // --- Collapsibles --------------------------------------------------------- + // Collapsibles. function buildCollapsible(key, label, count) { const wrap = document.createElement('div'); @@ -8775,7 +10131,7 @@ void main() { return s.replace(/\s+#.*$/, '').trim(); } - // --- Raw tab: minimal markdown renderer (subset) -------------------------- + // Raw tab: minimal markdown renderer (subset) function renderRawTab(body, md) { const wrap = document.createElement('div'); @@ -8908,9 +10264,9 @@ void main() { } catch { /* ignore */ } } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // function init() { try { history.scrollRestoration = 'manual'; } catch {} diff --git a/.agents/skills/impeccable/scripts/live-completion.mjs b/.agents/skills/impeccable/scripts/live-completion.mjs index 86b637fff..986773066 100644 --- a/.agents/skills/impeccable/scripts/live-completion.mjs +++ b/.agents/skills/impeccable/scripts/live-completion.mjs @@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.mode === 'error') return 'error'; + if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error'; return 'agent_done'; } diff --git a/.agents/skills/impeccable/scripts/live-inject.mjs b/.agents/skills/impeccable/scripts/live-inject.mjs index b9d3df41d..3a1f36e46 100644 --- a/.agents/skills/impeccable/scripts/live-inject.mjs +++ b/.agents/skills/impeccable/scripts/live-inject.mjs @@ -17,11 +17,38 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './impeccable-paths.mjs'; +import { + applySvelteKitLiveAdapter, + detectSvelteKitProject, + removeSvelteKitLiveAdapter, +} from './live-sveltekit-adapter.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start'; +const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end'; + +export const LIVE_IGNORE_PATTERNS = Object.freeze([ + '.impeccable/hook.cache.json', + '.impeccable/live/server.json', + '.impeccable/live/sessions/', + '.impeccable/live/previews/', + '.impeccable/live/annotations/', + '.impeccable/live/cache/', + '.impeccable/live/manual-edit-apply-transaction.json', + '.impeccable/live/manual-edit-events.jsonl', + '.impeccable/live/manual-edit-evidence/', + '.impeccable/live/pending-manual-edits.json', + '.impeccable/live/deferred-svelte-component-accepts.json', + '.impeccable-live.json', + '.impeccable-live/', + 'node_modules/.impeccable-live/', + 'src/lib/impeccable/ImpeccableLiveRoot.svelte', + 'src/lib/impeccable/__runtime.js', + 'src/lib/impeccable/[0-9a-f]*/', +]); /** * Hard-excluded directory patterns. These are NEVER user-facing pages and @@ -83,8 +110,14 @@ Output (JSON): validateConfig(config); const resolvedFiles = resolveFiles(process.cwd(), config); + const svelteKit = detectSvelteKitProject(process.cwd(), config); if (args.includes('--remove')) { + if (svelteKit) { + const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config }); + console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; @@ -110,6 +143,13 @@ Output (JSON): console.error(JSON.stringify({ ok: false, error: 'missing_port' })); process.exit(1); } + const gitIgnore = ensureLiveGitIgnores(process.cwd()); + + if (svelteKit) { + const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config }); + console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); @@ -129,10 +169,68 @@ Output (JSON): }; }); const anyInserted = results.some((r) => r.inserted); - console.log(JSON.stringify({ ok: anyInserted, port, results })); + console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results })); if (!anyInserted) process.exit(1); } +export function ensureLiveGitIgnores(cwd = process.cwd()) { + const target = resolveIgnoreTarget(cwd); + const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : ''; + const block = [ + IGNORE_MARKER_OPEN, + ...LIVE_IGNORE_PATTERNS, + IGNORE_MARKER_CLOSE, + ].join('\n'); + const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`); + + let updated; + if (markerRe.test(existing)) { + updated = existing.replace(markerRe, block); + } else { + const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n'; + updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`; + } + + if (updated !== existing) { + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + fs.writeFileSync(target.path, updated, 'utf-8'); + } + + return { + file: path.relative(cwd, target.path).split(path.sep).join('/'), + mode: target.mode, + changed: updated !== existing, + patterns: [...LIVE_IGNORE_PATTERNS], + }; +} + +function resolveIgnoreTarget(cwd) { + const gitExcludePath = resolveGitInfoExcludePath(cwd); + if (gitExcludePath) { + return { path: gitExcludePath, mode: 'git-info-exclude' }; + } + return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' }; +} + +function resolveGitInfoExcludePath(cwd) { + const dotGit = path.join(cwd, '.git'); + if (!fs.existsSync(dotGit)) return null; + + const stat = fs.statSync(dotGit); + if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude'); + if (!stat.isFile()) return null; + + const body = fs.readFileSync(dotGit, 'utf-8').trim(); + const match = body.match(/^gitdir:\s*(.+)$/i); + if (!match) return null; + const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]); + return path.join(gitDir, 'info', 'exclude'); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Expand config.files (which may contain glob patterns) into a literal list * of existing file paths relative to rootDir. Literal entries pass through; diff --git a/.agents/skills/impeccable/scripts/live-insert.mjs b/.agents/skills/impeccable/scripts/live-insert.mjs index 09d4d55be..0658e9914 100644 --- a/.agents/skills/impeccable/scripts/live-insert.mjs +++ b/.agents/skills/impeccable/scripts/live-insert.mjs @@ -21,6 +21,11 @@ import { buildCssAuthoring, buildCssSelectorPrefixExamples, } from './live-wrap.mjs'; +import { + buildSvelteComponentCssAuthoring, + scaffoldSvelteComponentInsertSession, + shouldUseSvelteComponentInjection, +} from './live-svelte-component.mjs'; const INSERT_POSITIONS = new Set(['before', 'after']); @@ -192,6 +197,41 @@ Output (JSON): const styleMode = detectStyleMode(targetFile); const isJsx = commentSyntax.open === '{/*'; const spliceIndex = computeInsertLine(startLine, endLine, position); + const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); + + if (shouldUseSvelteComponentInjection(targetFile)) { + const session = scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile: relTargetFile, + insertLine: spliceIndex + 1, + position, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + anchorLines: lines.slice(startLine, endLine + 1), + cwd: process.cwd(), + }); + console.log(JSON.stringify({ + mode: 'insert', + position, + file: session.manifestFile, + sourceFile: relTargetFile, + previewMode: 'svelte-component', + componentDir: session.componentDir, + propContract: session.propContract, + insertLine: 1, + sourceInsertLine: spliceIndex + 1, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + commentSyntax, + styleMode: 'svelte-component', + styleTag: null, + cssSelectorPrefixExamples: [], + cssAuthoring: buildSvelteComponentCssAuthoring(count), + })); + return; + } + const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? ''; @@ -216,7 +256,7 @@ Output (JSON): console.log(JSON.stringify({ mode: 'insert', position, - file: path.relative(process.cwd(), targetFile), + file: relTargetFile, insertLine: insertLine + 1, commentSyntax, styleMode: styleMode.mode, diff --git a/.agents/skills/impeccable/scripts/live-poll.mjs b/.agents/skills/impeccable/scripts/live-poll.mjs index fad836612..1e1259fbc 100644 --- a/.agents/skills/impeccable/scripts/live-poll.mjs +++ b/.agents/skills/impeccable/scripts/live-poll.mjs @@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs'; // that ceiling and loop in `pollOnce` to synthesize a long poll without // depending on the standalone undici package. export const PER_REQUEST_TIMEOUT_MS = 270_000; +export const DEFAULT_EVENT_LEASE_MS = 600_000; const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); @@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) { ? totalDeadline - Date.now() : PER_REQUEST_TIMEOUT_MS; const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); - const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); + const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`); if (res.status === 401) { const err = new Error('Authentication failed. The server token may have changed.'); @@ -317,7 +318,7 @@ Modes: Options: --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) - --file PATH Attach a source file path to the reply (generate flow) + --file PATH Attach a source file path to the reply (generate/steer flow) --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --help Show this help message diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs index 16c8285b9..cd1091b88 100644 --- a/.agents/skills/impeccable/scripts/live-server.mjs +++ b/.agents/skills/impeccable/scripts/live-server.mjs @@ -42,6 +42,10 @@ import { } from './live-manual-edits-buffer.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs'; +import { + applyDeferredSvelteComponentAccepts, + removeAllSvelteComponentSessions, +} from './live-svelte-component.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated @@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; +const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2; const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); function tombstoneTimedOutApplyId(eventId, details = {}) { @@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) { return entry.event; } entry.leaseUntil = Date.now() + leaseMs; + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return entry.event; } @@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) { const acknowledged = state.pendingEvents[idx].event; state.pendingEvents.splice(idx, 1); scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return acknowledged; } +function findPendingEventById(id) { + if (!id) return null; + const entry = state.pendingEvents.find((item) => item.event?.id === id); + return entry?.event || null; +} + function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; return `live-poll.mjs --reply ${id} done --data ''`; @@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) { return summary; } +function summarizeActiveSessionForClient(snapshot = {}) { + return { + id: snapshot.id, + phase: snapshot.phase, + pageUrl: snapshot.pageUrl ?? null, + sourceFile: snapshot.sourceFile ?? null, + previewFile: snapshot.previewFile ?? null, + previewMode: snapshot.previewMode ?? null, + expectedVariants: snapshot.expectedVariants ?? 0, + arrivedVariants: snapshot.arrivedVariants ?? 0, + visibleVariant: snapshot.visibleVariant ?? null, + checkpointRevision: snapshot.checkpointRevision ?? 0, + paramValues: snapshot.paramValues || {}, + }; +} + +function activeSessionSummaries() { + if (!state.sessionStore) return []; + return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot)); +} + +function cancelQueuedAnonymousExitEvents() { + let removed = 0; + for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) { + const event = state.pendingEvents[i]?.event; + if (event?.type !== 'exit' || event.id) continue; + state.pendingEvents.splice(i, 1); + removed += 1; + } + if (removed > 0) { + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); + } + return removed; +} + function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { const canceledById = new Map(); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); @@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() { clearTimeout(state.leaseTimer); state.leaseTimer = null; } - if (state.pendingPolls.length === 0) return; const now = Date.now(); const nextLeaseUntil = state.pendingEvents .map((entry) => entry.leaseUntil || 0) @@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() { state.leaseTimer = setTimeout(() => { state.leaseTimer = null; flushPendingPolls(); - }, Math.max(0, nextLeaseUntil - now)); + broadcastAgentPollingIfChanged(); + }, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS)); } function flushPendingPolls() { @@ -1032,7 +1082,9 @@ function flushPendingPolls() { } function agentPollingConnected() { - return state.pendingPolls.length > 0; + const now = Date.now(); + return state.pendingPolls.length > 0 + || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now); } function broadcastAgentPollingIfChanged() { @@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/status') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } - const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; + const sessions = activeSessionSummaries(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', @@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/events' && req.method === 'GET') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + clearTimeout(state.exitTimer); + state.exitTimer = null; + cancelQueuedAnonymousExitEvents(); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { type: 'connected', hasProjectContext: hasProjectContext(), agentPolling: agentPollingConnected(), + activeSessions: activeSessionSummaries(), }) + '\n\n'); state.sseClients.add(res); - clearTimeout(state.exitTimer); // Keepalive: SSE comment every 30s prevents silent connection drops. const heartbeat = setInterval(() => { @@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } } + if (msg.type === 'exit') { + cleanupSvelteComponentSessionsBeforeExit(); + } if (msg.type !== 'checkpoint') { enqueueEvent(msg); } @@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) { }); } +function sessionFileMetadataFromPollReply(file) { + if (!file || typeof file !== 'string') return { file }; + const normalized = file.split(path.sep).join('/'); + const base = { file: normalized }; + if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base; + if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base; + + let full; + try { + full = path.resolve(process.cwd(), normalized); + const rel = path.relative(process.cwd(), full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base; + } catch { + return base; + } + + try { + const manifest = JSON.parse(fs.readFileSync(full, 'utf-8')); + if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base; + return { + file: String(manifest.sourceFile).split(path.sep).join('/'), + sourceFile: String(manifest.sourceFile).split(path.sep).join('/'), + previewFile: normalized, + previewMode: 'svelte-component', + }; + } catch { + return base; + } +} + function handlePollPost(req, res) { let body = ''; req.on('data', (c) => { body += c; }); @@ -1965,6 +2053,16 @@ function handlePollPost(req, res) { res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); return; } + const pendingEventBeforeAck = findPendingEventById(msg.id); + if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done' + && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'steer_done_requires_file_or_message', + hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.', + })); + return; + } const acknowledgedEvent = acknowledgePendingEvent(msg.id); let skipJournalReply = false; let existingSession = null; @@ -1987,6 +2085,7 @@ function handlePollPost(req, res) { })); return; } + const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); if (state.sessionStore && msg.id && !skipJournalReply) { try { const eventType = msg.type === 'steer_done' @@ -2001,7 +2100,10 @@ function handlePollPost(req, res) { state.sessionStore.appendEvent({ type: eventType, id: msg.id, - file: msg.file, + file: replyFileMeta.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, message: msg.message, sourceEventType: acknowledgedEvent?.type, carbonize: msg.data?.carbonize === true, @@ -2010,7 +2112,16 @@ function handlePollPost(req, res) { } flushPendingPolls(); // Forward the reply to the browser via SSE - broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + broadcast({ + type: msg.type || 'done', + id: msg.id, + message: msg.message, + file: msg.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, + data: msg.data, + }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); @@ -2023,6 +2134,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { + cleanupSvelteComponentSessionsBeforeExit(); removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; @@ -2037,6 +2149,25 @@ function shutdown() { process.exit(0); } +function cleanupSvelteComponentSessionsBeforeExit() { + try { + removeAllSvelteComponentSessions(process.cwd()); + } catch (err) { + console.warn('[impeccable] Svelte component session cleanup failed:', err.message); + } +} + +function applyLegacyDeferredAcceptsOnStartup() { + try { + const result = applyDeferredSvelteComponentAccepts(process.cwd()); + if (result.applied > 0 || result.failed > 0) { + console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result)); + } + } catch (err) { + console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({ cwd: process.cwd(), reason: 'manual_edit_server_start_recovered_abandoned_transaction', }); +applyLegacyDeferredAcceptsOnStartup(); restorePendingEventsFromStore(); pruneStaleManualApplyEvidence(process.cwd()); const portArg = args.find(a => a.startsWith('--port=')); diff --git a/.agents/skills/impeccable/scripts/live-session-store.mjs b/.agents/skills/impeccable/scripts/live-session-store.mjs index 7562e3d5b..5ec4d34d4 100644 --- a/.agents/skills/impeccable/scripts/live-session-store.mjs +++ b/.agents/skills/impeccable/scripts/live-session-store.mjs @@ -106,6 +106,8 @@ function baseSnapshot(id) { phase: 'new', pageUrl: null, sourceFile: null, + previewFile: null, + previewMode: null, expectedVariants: 0, arrivedVariants: 0, visibleVariant: null, @@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; - next.sourceFile = event.file ?? next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0); next.pendingEventSeq = null; next.pendingEvent = null; if (event.carbonize === true) { @@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { } break; case 'checkpoint': + if (COMPLETED_PHASES.has(next.phase)) { + next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null }); + break; + } if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { next.phase = event.phase ?? next.phase; next.checkpointRevision = event.revision ?? next.checkpointRevision; next.activeOwner = event.owner ?? next.activeOwner; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.visibleVariant = event.visibleVariant ?? next.visibleVariant; + next.sourceFile = event.sourceFile ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; if (event.paramValues) next.paramValues = { ...event.paramValues }; } else { next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); @@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'steer_done': next.phase = 'steer_done'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.message = event.message ?? next.message; next.pendingEventSeq = null; next.pendingEvent = null; break; @@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'complete': next.phase = 'completed'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; next.pendingEventSeq = null; next.pendingEvent = null; break; diff --git a/.agents/skills/impeccable/scripts/live-svelte-component.mjs b/.agents/skills/impeccable/scripts/live-svelte-component.mjs new file mode 100644 index 000000000..dc35dc0ce --- /dev/null +++ b/.agents/skills/impeccable/scripts/live-svelte-component.mjs @@ -0,0 +1,826 @@ +/** + * Svelte live-mode component injection helpers. + * + * Variants are real .svelte components under node_modules/.impeccable-live//. + * The browser mounts them via Svelte 5 mount(); accept inlines the chosen + * variant back into the route source with props mapped to original bindings. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; + +export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live'; +export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`; +export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json'; + +const MUSTACHE_RE = /\{([^{}]+)\}/g; + +export function shouldUseSvelteComponentInjection(filePath) { + if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false; + return path.extname(filePath).toLowerCase() === '.svelte'; +} + +export function componentSessionDir(id, cwd = process.cwd()) { + return path.join(cwd, SVELTE_COMPONENT_ROOT, id); +} + +export function manifestPathForSession(id, cwd = process.cwd()) { + return path.join(componentSessionDir(id, cwd), 'manifest.json'); +} + +export function ensureRuntimeHelper(cwd = process.cwd()) { + const file = path.join(cwd, SVELTE_RUNTIME_FILE); + if (fs.existsSync(file)) return file; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8'); + return file; +} + +/** + * Extract ordered unique mustache expressions from markup (not inside ). + */ +export function extractMustacheExpressions(text) { + const expressions = []; + const seen = new Set(); + const lines = String(text || '').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('\n` + : ''; + return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n\n`; +} + +function buildInsertVariantStub(variantNum) { + return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; +} + +export function scaffoldSvelteComponentSession({ + id, + count, + sourceFile, + sourceStartLine, + sourceEndLine, + originalLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const originalMarkup = originalLines.join('\n'); + const contract = buildPropContract(extractMustacheExpressions(originalMarkup)); + const originalWithProps = substituteExprsWithProps(originalMarkup, contract); + + const manifest = { + id, + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + sourceStartLine, + sourceEndLine, + count, + propContract: contract, + originalMarkup, + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: contract, + }; +} + +export function scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile, + insertLine, + position, + anchorStartLine, + anchorEndLine, + anchorLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const anchorMarkup = (anchorLines || []).join('\n'); + const manifest = { + id, + mode: 'insert', + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + insertLine, + position, + anchorStartLine, + anchorEndLine, + originalMarkup: anchorMarkup, + anchorMarkup, + count, + propContract: [], + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: [], + }; +} + +export function findSvelteComponentManifest(id, cwd = process.cwd()) { + const direct = manifestPathForSession(id, cwd); + if (fs.existsSync(direct)) { + return readManifest(direct); + } + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return null; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(root, entry.name, 'manifest.json'); + if (!fs.existsSync(candidate)) continue; + try { + const manifest = readManifest(candidate); + if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; + } catch { /* skip */ } + } + return null; +} + +export function readManifest(manifestPath) { + const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + return { + ...data, + manifestPath, + }; +} + +export function resolveSourceFile(sourceFile, cwd = process.cwd()) { + if (!sourceFile || path.isAbsolute(sourceFile)) { + throw new Error('Invalid svelte-component source file'); + } + const full = path.resolve(cwd, sourceFile); + const rel = path.relative(cwd, full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('Svelte-component source file escapes project root'); + } + if (!fs.existsSync(full)) { + throw new Error('Svelte-component source file not found: ' + sourceFile); + } + return full; +} + +function appendCssToSvelteStyle(lines, cssLines) { + const closeIdx = findLastStyleCloseLine(lines); + const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; + if (closeIdx === -1) { + return [...lines, '', '']; + } + return [ + ...lines.slice(0, closeIdx), + ...prepared, + ...lines.slice(closeIdx), + ]; +} + +function findLastStyleCloseLine(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + if (/<\/style\s*>/.test(lines[i])) return i; + } + return -1; +} + +function bakeParamValuesInCss(cssLines, paramValues) { + if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; + return cssLines.map((line) => { + let out = line; + for (const [key, value] of Object.entries(paramValues)) { + const varName = `--p-${key}`; + out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); + } + return out; + }); +} + +function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { + const css = String((cssLines || []).join('\n')); + if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; + + const rules = parseCssRules(css); + const output = []; + for (const rule of rules) { + appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); + } + return output.join('\n') + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ''); +} + +function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { + const prelude = rule.prelude.trim(); + const body = rule.body.trim(); + if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; + + if (/^@scope\b/i.test(prelude)) { + if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; + const inner = parseCssRules(body); + for (const innerRule of inner) { + const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); + if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; + output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); + } + return; + } + + const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); + if (!rewrittenPrelude) return; + output.push(formatCssRule(rewrittenPrelude, body)); +} + +function parseCssRules(css) { + const rules = []; + const text = String(css || ''); + let i = 0; + while (i < text.length) { + while (i < text.length && /\s/.test(text[i])) i++; + const preludeStart = i; + while (i < text.length && text[i] !== '{') i++; + if (i >= text.length) break; + const prelude = text.slice(preludeStart, i).trim(); + i++; + const bodyStart = i; + let depth = 1; + let quote = null; + let comment = false; + while (i < text.length && depth > 0) { + const ch = text[i]; + const next = text[i + 1]; + if (comment) { + if (ch === '*' && next === '/') { + comment = false; + i += 2; + continue; + } + i++; + continue; + } + if (quote) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === quote) quote = null; + i++; + continue; + } + if (ch === '/' && next === '*') { + comment = true; + i += 2; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); + if (prelude) rules.push({ prelude, body }); + } + return rules; +} + +function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { + const selectors = splitSelectorList(prelude); + const rewritten = []; + for (const selector of selectors) { + const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); + if (next) rewritten.push(next); + } + return rewritten.join(', '); +} + +function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { + let out = selector.trim(); + const hasVariant = /data-impeccable-variant/.test(out); + if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; + if (hasVariant) { + out = out.replace(variantSelectorRegex(variantNum), ''); + out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); + } + + const paramResult = rewriteParamSelectors(out, paramValues); + if (!paramResult.keep) return ''; + out = paramResult.selector; + + out = out + .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') + .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') + .replace(/\s+/g, ' ') + .trim(); + + out = out.replace(/^[>+~]\s*/, '').trim(); + if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; + return out; +} + +function rewriteParamSelectors(selector, paramValues) { + let keep = true; + const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { + if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; + const actual = paramValues[key]; + if (expected != null && String(actual) !== String(expected)) { + keep = false; + return ''; + } + if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { + keep = false; + return ''; + } + return ''; + }); + return { keep, selector: next }; +} + +function splitSelectorList(prelude) { + const selectors = []; + let start = 0; + let bracket = 0; + let paren = 0; + let quote = null; + for (let i = 0; i < prelude.length; i++) { + const ch = prelude[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '[') bracket++; + else if (ch === ']') bracket = Math.max(0, bracket - 1); + else if (ch === '(') paren++; + else if (ch === ')') paren = Math.max(0, paren - 1); + else if (ch === ',' && bracket === 0 && paren === 0) { + selectors.push(prelude.slice(start, i)); + start = i + 1; + } + } + selectors.push(prelude.slice(start)); + return selectors; +} + +function selectorHasVariant(selector, variantNum) { + return variantSelectorRegex(variantNum).test(selector); +} + +function variantSelectorRegex(variantNum) { + return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); +} + +function formatCssRule(selector, body) { + return `${selector} { ${body.trim()} }`; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { + const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); + const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); + const resultBase = { + file: manifest.sourceFile, + sourceFile: manifest.sourceFile, + previewMode: 'svelte-component', + componentDir: manifest.componentDir, + carbonize: false, + }; + if (!fs.existsSync(variantPath)) { + return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; + } + + const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); + if (manifest.mode === 'insert') { + return inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, + }); + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const contract = manifest.propContract || []; + const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); + const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract) + .split('\n') + .map((line) => line.trimEnd()); + + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const start = Number(manifest.sourceStartLine) - 1; + const end = Number(manifest.sourceEndLine) - 1; + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { + return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; + } + + const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, start), + ...indentedMarkup, + ...sourceLines.slice(end + 1), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, +}) { + if (!svelteMarkupHasVisibleContent(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; + } + if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const restoredMarkup = String(markup || '') + .split('\n') + .map((line) => line.trimEnd()); + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const insertIndex = Number(manifest.insertLine) - 1; + if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { + return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; + } + + const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; + const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, insertIndex), + ...indentedMarkup, + ...sourceLines.slice(insertIndex), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function svelteMarkupHasVisibleContent(markup) { + const text = String(markup || '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 0) return true; + return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); +} + +function mergeOriginalTopLevelAttrs(markup, originalMarkup) { + const variantOpen = matchOpeningTag(markup); + const originalOpen = matchOpeningTag(originalMarkup); + if (!variantOpen || !originalOpen) return markup; + if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; + + const variantAttrs = parseAttrSegments(variantOpen.attrs); + const originalAttrs = parseAttrSegments(originalOpen.attrs); + const additions = []; + let attrs = variantOpen.attrs; + + const originalClass = originalAttrs.get('class'); + const variantClass = variantAttrs.get('class'); + if (originalClass && variantClass) { + const merged = mergeStaticClassAttr(originalClass, variantClass); + if (merged) { + attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); + variantAttrs.set('class', { ...variantClass, raw: merged }); + } + } else if (originalClass && !variantClass) { + additions.push(originalClass.raw); + } + + for (const [name, attr] of originalAttrs) { + if (name === 'class') continue; + if (!variantAttrs.has(name)) additions.push(attr.raw); + } + + if (additions.length === 0 && attrs === variantOpen.attrs) return markup; + const nextOpen = variantOpen.prefix + + variantOpen.tag + + attrs + + additions.map((attr) => ' ' + attr.trim()).join('') + + variantOpen.close; + return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); +} + +function matchOpeningTag(markup) { + const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); + if (!match) return null; + return { + raw: match[0], + prefix: match[1], + tag: match[2], + attrs: match[3] || '', + close: match[4], + index: match.index || 0, + }; +} + +function parseAttrSegments(attrs) { + const out = new Map(); + const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; + let match; + while ((match = re.exec(attrs))) { + const raw = match[0]; + const name = match[1]; + out.set(name, { + name, + raw, + start: match.index, + end: match.index + raw.length, + }); + } + return out; +} + +function mergeStaticClassAttr(originalClass, variantClass) { + const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + if (!originalValue || !variantValue) return null; + const quote = variantValue[1]; + const classes = [ + ...variantValue[2].split(/\s+/), + ...originalValue[2].split(/\s+/), + ].filter(Boolean); + return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; +} + +export function removeSvelteComponentSession(id, cwd = process.cwd()) { + const dir = componentSessionDir(id, cwd); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* non-fatal */ } +} + +export function removeAllSvelteComponentSessions(cwd = process.cwd()) { + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('__')) continue; + try { + fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + } catch { /* non-fatal */ } + } +} + +export function deferredAcceptsPath(cwd = process.cwd()) { + const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16); + return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json'); +} + +export function readDeferredAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch { + return { accepts: [] }; + } +} + +export function writeDeferredAccept(entry, cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const data = readDeferredAccepts(cwd); + data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id); + data.accepts.push({ ...entry, createdAt: new Date().toISOString() }); + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + const data = readDeferredAccepts(cwd); + const pending = Array.isArray(data.accepts) ? data.accepts : []; + const results = []; + const remaining = []; + for (const entry of pending) { + try { + const manifest = findSvelteComponentManifest(entry.id, cwd); + if (!manifest) { + results.push({ id: entry.id, ok: false, error: 'manifest not found' }); + remaining.push(entry); + continue; + } + const result = inlineSvelteComponentAccept( + manifest, + entry.variantNum, + entry.paramValues || null, + cwd, + ); + results.push({ id: entry.id, ok: result.handled !== false, result }); + if (result.handled === false) remaining.push(entry); + } catch (err) { + results.push({ id: entry.id, ok: false, error: err.message }); + remaining.push(entry); + } + } + if (remaining.length > 0) { + fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8'); + } else { + try { fs.rmSync(file, { force: true }); } catch {} + } + return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results }; +} + +export function buildSvelteComponentCssAuthoring(count) { + const variantNumbers = Array.from({ length: count }, (_, i) => i + 1); + return { + mode: 'svelte-component', + styleTag: null, + strategy: 'component-style-block', + rulePattern: '.semantic-class { ... }', + selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'), + requirements: [ + 'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).', + 'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.', + 'Put variant CSS in the component ' : '')); + if (paramValues && Object.keys(paramValues).length > 0) { + lines.push( + bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close, + ); + } + lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + lines.push(bodyIndent + '
'); + lines.push(...bodyRestored); + lines.push(bodyIndent + '
'); + }; + + if (isJsx) { + const wrapperStyle = 'style={{ display: "contents" }}'; + lines.push(indent + '
'); + pushCarbonizeBody(indent + ' '); + lines.push(indent + '
'); + } else { + pushCarbonizeBody(indent); + } + + return lines; +} + +function reindentContent(contentLines, fromIndent, toIndent) { + return contentLines.map((line) => { + if (line.trim() === '') return ''; + if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length); + return toIndent + line.trimStart(); + }); +} + function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; @@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const needsCarbonize = !!(cssContent || hasHelperAttrs); - // Build the replacement const restored = deindentContent(variantContent, indent); - const replacement = []; - - if (cssContent) { - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); - // JSX targets need the CSS body wrapped in a template literal so that the - // `{` and `}` in CSS rules don't get parsed as JSX expressions. - replacement.push(indent + '' : '')); - if (paramValues && Object.keys(paramValues).length > 0) { - // Preserve the user's knob positions for the carbonize-cleanup agent - // to bake into the final CSS when it collapses scoped rules. - replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); - } - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); - } - - // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the - // carbonize CSS block working visually by re-wrapping the accepted content - // in a data-impeccable-variant="N" div with `display: contents` (so layout - // isn't affected). The carbonize agent strips this attribute + wrapper when - // it moves the CSS to a proper stylesheet. - // - // Style attribute syntax has to follow the host file's flavor — JSX files - // need the object form, otherwise React 19 throws "Failed to set indexed - // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. - if (cssContent) { - const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; - replacement.push(indent + '
'); - replacement.push(...restored); - replacement.push(indent + '
'); - } else { - replacement.push(...restored); - } + const replacement = buildCarbonizeReplacement({ + indent, + commentSyntax, + isJsx, + id, + variantNum, + cssContent, + paramValues, + restored, + }); const newLines = [ ...lines.slice(0, replaceRange.start), @@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; } +function readSourceShadowPreviewMeta(content, id) { + const escaped = escapeRegExp(id); + const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>'); + const match = String(content || '').match(wrapperRe); + if (!match) return null; + const tag = match[0]; + if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null; + const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file'); + const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start')); + const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end')); + if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null; + return { sourceFile, sourceStartLine, sourceEndLine }; +} + +function readHtmlAttr(tag, name) { + const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1')); + if (!match) return null; + return decodeHtmlAttr(match[2]); +} + +function decodeHtmlAttr(value) { + return String(value || '') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- @@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts }; diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index f609ac847..d7c580e2f 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -26,9 +26,9 @@ return; } - // --------------------------------------------------------------------------- + // // Design tokens - // --------------------------------------------------------------------------- + // // Brand kinpaku (gold) is pinned to the site's neo-kinpaku tokens // (see site/styles/kinpaku-tokens.css) so Accept / knobs / cycle-dots / @@ -115,19 +115,48 @@ { value: 'overdrive', label: 'Overdrive' }, ]; - // --------------------------------------------------------------------------- + const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions']; + const LIVE_UI_SURFACES = [ + { 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'] }, + { key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] }, + { key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-configure-input-wrap', PREFIX + '-input', PREFIX + '-configure-voice'] }, + { key: 'action-picker', ids: [PREFIX + '-picker'] }, + { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, + { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, + { key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] }, + { key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] }, + { key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] }, + { 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'] }, + { key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] }, + { key: 'design-system-panel', ids: [PREFIX + '-design-host'] }, + { key: 'toasts-and-errors', ids: [PREFIX + '-toast'] }, + { key: 'css-isolation-boundary', ids: [PREFIX + '-root'] }, + ]; + const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))]; + + // // State - // --------------------------------------------------------------------------- + // let state = 'IDLE'; let hoveredElement = null; let selectedElement = null; let currentSessionId = null; - let pendingAcceptedSession = null; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; + let svelteComponentSession = null; + let svelteRuntimePromise = null; + let pendingSvelteComponentRetryObserver = null; + let currentSourceFile = null; + let currentPreviewFile = null; + let currentPreviewMode = null; + let recoveryWaitingForAnchor = false; + let pendingAcceptedSession = null; let variantObserver = null; + let variantSelectionInFlight = false; + let variantSelectionPromise = null; + let recoveringEmptyCycling = false; let hasProjectContext = false; let selectedAction = 'impeccable'; let selectedCount = 3; @@ -175,14 +204,17 @@ let highlightEl = null; let tooltipEl = null; let barEl = null; + let barHideSeq = 0; let pickerEl = null; let toastEl = null; let scrollRaf = null; let editBadgeEl = null; + let editBadgeProxyRoot = null; + let editBadgeProxyByTarget = new Map(); - // --------------------------------------------------------------------------- + // // Helpers - // --------------------------------------------------------------------------- + // function own(el) { return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); @@ -204,8 +236,105 @@ return s; } + function rectIsUsableAnchor(rect) { + return !!rect && rect.width > 0.5 && rect.height > 0.5; + } + + function makeFrozenAnchor(el) { + if (!el || !el.getBoundingClientRect) return null; + const r = el.getBoundingClientRect(); + if (!rectIsUsableAnchor(r)) return null; + const rect = { + x: r.x, y: r.y, + top: r.top, left: r.left, + right: r.right, bottom: r.bottom, + width: r.width, height: r.height, + }; + return { + __impeccableFrozenAnchor: true, + tagName: el.tagName || 'DIV', + id: el.id || '', + classList: el.classList ? [...el.classList] : [], + hasAttribute: () => false, + getBoundingClientRect: () => rect, + }; + } + function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } + function cssId(id) { + if (window.CSS?.escape) return CSS.escape(id); + return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); + } + + function liveUiRoot() { + const root = window.__IMPECCABLE_LIVE_UI_ROOT__; + if (root && typeof root.appendChild === 'function') return root; + return document.body; + } + + function uiAppend(el) { + liveUiRoot().appendChild(el); + return el; + } + + function uiAppendStyle(styleEl) { + const root = liveUiRoot(); + if (root && root !== document.body) root.appendChild(styleEl); + else document.head.appendChild(styleEl); + return styleEl; + } + + function uiGetById(id) { + const root = liveUiRoot(); + if (root?.getElementById) { + const found = root.getElementById(id); + if (found) return found; + } + if (root?.querySelector) { + const found = root.querySelector('#' + cssId(id)); + if (found) return found; + } + return document.getElementById(id); + } + + function activeElementDeep() { + let active = document.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; + return active; + } + + window.__IMPECCABLE_LIVE_CHROME_CORE__ = { + version: 1, + adapter: window.__IMPECCABLE_LIVE_ADAPTER__ || 'dom', + mountContract: LIVE_CHROME_MOUNT_CONTRACT, + surfaces: LIVE_UI_SURFACES, + componentIds: LIVE_UI_COMPONENT_IDS, + root: liveUiRoot, + append: uiAppend, + appendStyle: uiAppendStyle, + getById: uiGetById, + activeElementDeep, + debugState: () => ({ + state, + currentSessionId, + expectedVariants, + arrivedVariants, + visibleVariant, + savedSession: loadSession(), + sourceFile: currentSourceFile, + previewFile: currentPreviewFile, + previewMode: currentPreviewMode, + barText: barEl?.textContent || null, + barConnected: !!barEl?.isConnected, + hasSvelteComponentSession: !!svelteComponentSession, + mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0, + pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver, + recoveryWaitingForAnchor, + evtSourceReadyState: evtSource ? evtSource.readyState : null, + }), + }; + // Modal-aware chrome: keep our floating UI clickable inside Radix / // Headless UI / vaul portals. // @@ -245,9 +374,9 @@ rootEl.addEventListener('focusin', stop); } - // --------------------------------------------------------------------------- + // // Highlight overlay - // --------------------------------------------------------------------------- + // function initHighlight() { highlightEl = document.createElement('div'); @@ -259,7 +388,7 @@ transition: HIGHLIGHT_TRANSITION, display: 'none', opacity: '0', }); - document.body.appendChild(highlightEl); + uiAppend(highlightEl); tooltipEl = document.createElement('div'); tooltipEl.id = PREFIX + '-tooltip'; @@ -273,7 +402,7 @@ letterSpacing: '0.02em', transition: TOOLTIP_TRANSITION, }); - document.body.appendChild(tooltipEl); + uiAppend(tooltipEl); } function showHighlight(el) { @@ -310,7 +439,7 @@ if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; } } - // --------------------------------------------------------------------------- + // // Annotation overlay (comment pins + kinpaku strokes) // // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned @@ -318,7 +447,7 @@ // drag) drops a comment pin; drag paints a kinpaku SVG stroke. All coords // are stored in element-local CSS px so they survive scroll / resize and // correlate directly with the captured PNG. - // --------------------------------------------------------------------------- + // const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it @@ -397,7 +526,7 @@ annotOverlayEl.addEventListener('pointermove', onAnnotMove); annotOverlayEl.addEventListener('pointerup', onAnnotUp); annotOverlayEl.addEventListener('pointercancel', onAnnotUp); - document.body.appendChild(annotOverlayEl); + uiAppend(annotOverlayEl); // Modal-host friendliness: pointer-events is already 'auto' on this // overlay; we only need to silence the host's outside-interaction // listeners. Don't override pointer-events here (the overlay toggles @@ -828,9 +957,9 @@ return wrap; } - // --------------------------------------------------------------------------- + // // Element context extraction - // --------------------------------------------------------------------------- + // function stripManualEditRuntimeState(root) { if (!root || root.nodeType !== 1) return; @@ -971,9 +1100,9 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } - // --------------------------------------------------------------------------- + // // The Bar - one floating element, three modes - // --------------------------------------------------------------------------- + // // Contextual-bar palette. Cached at init so every build*Row reads a // consistent set of colors; detectPageTheme runs once rather than on every @@ -1006,7 +1135,7 @@ padding: '6px', maxWidth: '520px', minWidth: '320px', }); - document.body.appendChild(barEl); + uiAppend(barEl); defangOutsideHandlers(barEl); } @@ -1041,6 +1170,8 @@ } function showBar(mode) { + barHideSeq += 1; + if (mode === 'cycling' && !ensureCyclingRenderable('show-bar')) return; barEl.innerHTML = ''; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); @@ -1058,11 +1189,12 @@ function hideBar() { if (!barEl) return; + const hideSeq = ++barHideSeq; stopVoice({ suppressSubmit: true }); if (configureKind === 'insert') clearInsertPicking(); barEl.style.opacity = '0'; barEl.style.transform = 'translateY(6px)'; - setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); + setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); if (state === 'EDITING') restoreInlineEditDrafts(); @@ -1071,6 +1203,7 @@ function updateBarContent(mode) { if (!barEl || barEl.style.display === 'none') return; + if (mode === 'cycling' && !ensureCyclingRenderable('update-bar')) return; barEl.innerHTML = ''; // Reset bar styling to the kinpaku picker palette barEl.style.background = BP.surface; @@ -1090,13 +1223,13 @@ syncPageChatFocus('update-bar-content'); } - // --- Configure row --- + // Configure row function syncConfigureInputChrome() { - const wrap = document.getElementById(PREFIX + '-configure-input-wrap'); - const input = document.getElementById(PREFIX + '-input'); + const wrap = uiGetById(PREFIX + '-configure-input-wrap'); + const input = uiGetById(PREFIX + '-input'); if (!wrap || !input) return; - const focused = document.activeElement === input; + const focused = activeElementDeep() === input; wrap.dataset.inputFocused = focused ? 'true' : 'false'; wrap.dataset.voiceListening = (voiceListening && voiceCtx?.mode === 'configure') ? 'true' : 'false'; wrap.style.borderColor = (voiceListening && voiceCtx?.mode === 'configure') @@ -1104,7 +1237,7 @@ : (focused ? BP.accentSoft : BP.hairline); } - // --- Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) --- + // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) function detectInsertAxisFromStyle(style) { const display = style?.display || 'block'; @@ -1377,7 +1510,7 @@ display: 'none', opacity: '0.9', }); - document.body.appendChild(insertLineEl); + uiAppend(insertLineEl); defangOutsideHandlers(insertLineEl); return insertLineEl; } @@ -1440,6 +1573,10 @@ /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { + if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor) return anchor; + } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (wrapper) { @@ -1557,6 +1694,11 @@ positionBar(); } + function showOrUpdateCyclingBar() { + if (barEl && barEl.style.display !== 'none') updateBarContent('cycling'); + else showBar('cycling'); + } + function buildPlaceholderResizeHandles() { if (!placeholderResizeLayerEl) return; placeholderResizeLayerEl.innerHTML = ''; @@ -1665,7 +1807,7 @@ } function isInsertCreateEnabled(btn) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); + btn = btn || uiGetById(PREFIX + '-insert-create'); return !!btn && btn.getAttribute('aria-disabled') !== 'true'; } @@ -1691,7 +1833,7 @@ lineHeight: '1.35', }); insertCreateTooltipEl.id = PREFIX + '-insert-create-tooltip'; - document.body.appendChild(insertCreateTooltipEl); + uiAppend(insertCreateTooltipEl); return insertCreateTooltipEl; } @@ -1723,8 +1865,8 @@ } function syncInsertCreateButton(btn, input) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); - input = input || document.getElementById(PREFIX + '-insert-input'); + btn = btn || uiGetById(PREFIX + '-insert-create'); + input = input || uiGetById(PREFIX + '-insert-input'); if (!btn || !input) return; const gate = insertCreateGateState(input); const ok = canCreateInsert(gate); @@ -1833,7 +1975,7 @@ voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!document.getElementById(PREFIX + '-configure-input-style')) { + if (!uiGetById(PREFIX + '-configure-input-style')) { const s = document.createElement('style'); s.id = PREFIX + '-configure-input-style'; s.textContent = @@ -1842,7 +1984,7 @@ '#' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: impeccable-configure-voice-pulse 1.1s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-configure-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } input.addEventListener('focus', () => syncConfigureInputChrome()); @@ -1949,6 +2091,9 @@ transition: 'border-color 0.15s ease', }); inputWrap.id = PREFIX + '-insert-input-wrap'; + inputWrap.addEventListener('pointerdown', (e) => e.stopPropagation()); + inputWrap.addEventListener('mousedown', (e) => e.stopPropagation()); + inputWrap.addEventListener('click', (e) => e.stopPropagation()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; @@ -1984,6 +2129,12 @@ voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; input.addEventListener('input', () => syncInsertCreateButton()); + input.addEventListener('pointerdown', (e) => e.stopPropagation()); + input.addEventListener('mousedown', (e) => e.stopPropagation()); + input.addEventListener('click', (e) => { + e.stopPropagation(); + try { input.focus({ preventScroll: true }); } catch { input.focus(); } + }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); @@ -2049,6 +2200,7 @@ }); create.addEventListener('mouseleave', hideInsertCreateTooltip); create.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); if (controlsLocked) { showManualApplyBusyToast(); return; } if (!isInsertCreateEnabled(create)) return; @@ -2060,7 +2212,7 @@ return row; } - // --- Generating row --- + // Generating row function buildGeneratingRow() { const row = el('div', { @@ -2086,19 +2238,24 @@ }); // Variants currently arrive atomically in a single file edit, so a // per-variant counter would lie. Say what's true. - status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + expectedVariants + ' variants...' - : 'Done'; + status.textContent = recoveryWaitingForAnchor + ? 'Variants ready. Reveal the selected element to resume.' + : (arrivedVariants < expectedVariants + ? 'Generating ' + expectedVariants + ' variants...' + : 'Done'); row.appendChild(status); return row; } - // --- Cycling row --- + // Cycling row const TUNE_ICON_SVG = ''; function buildCyclingRow() { + if (!ensureCyclingRenderable('build-cycling-row')) { + return el('div', { display: 'none' }); + } const row = el('div', { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 2px', @@ -2106,6 +2263,7 @@ // Prev const prev = navBtn('\u2190'); + prev.id = PREFIX + '-variant-prev'; prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); }); if (visibleVariant <= 1) prev.style.opacity = '0.3'; row.appendChild(prev); @@ -2118,11 +2276,13 @@ fontFamily: MONO, fontSize: '11px', fontWeight: '500', color: BP.textDim, minWidth: '24px', textAlign: 'center', }); + counter.id = PREFIX + '-variant-counter'; counter.textContent = visibleVariant + '/' + arrivedVariants; row.appendChild(counter); // Next const next = navBtn('\u2192'); + next.id = PREFIX + '-variant-next'; next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); }); if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3'; row.appendChild(next); @@ -2208,9 +2368,9 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders - // --- Saving row (waiting for agent to process accept/discard) --- + // Saving row (waiting for agent to process accept/discard) function buildSavingRow() { const row = el('div', { @@ -2235,7 +2395,7 @@ return row; } - // --- Confirmed row (green success, auto-dismisses) --- + // Confirmed row (green success, auto-dismisses) function buildConfirmedRow() { const row = el('div', { @@ -2256,7 +2416,7 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders function buildDots(clickable) { const container = el('div', { @@ -2290,10 +2450,7 @@ const idx = i; dot.addEventListener('click', (e) => { e.stopPropagation(); - visibleVariant = idx; - showVariantInDOM(currentSessionId, idx); - updateSelectedElement(); - updateBarContent('cycling'); + selectVariant(idx, 'variant_changed'); }); } container.appendChild(dot); @@ -2323,13 +2480,14 @@ function el(tag, styles) { const e = document.createElement(tag); + if (String(tag).toLowerCase() === 'button') e.type = 'button'; if (styles) Object.assign(e.style, styles); return e; } - // --------------------------------------------------------------------------- + // // Action picker popover - // --------------------------------------------------------------------------- + // function initActionPicker() { const P = barPaletteForTheme(detectPageTheme()); @@ -2384,16 +2542,20 @@ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent'; }); chip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + const prompt = uiGetById(PREFIX + '-input')?.value || ''; selectedAction = action.value; hideActionPicker(); updateBarContent('configure'); + const input = uiGetById(PREFIX + '-input'); + if (input && prompt) input.value = prompt; }); grid.appendChild(chip); }); pickerEl.appendChild(grid); - document.body.appendChild(pickerEl); + uiAppend(pickerEl); defangOutsideHandlers(pickerEl); // Cache the palette on the picker so toggleActionPicker's state refresh @@ -2433,7 +2595,33 @@ setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180); } - // --------------------------------------------------------------------------- + function ensureCyclingRenderable(reason) { + if (arrivedVariants > 0) { + if (visibleVariant < 1 || visibleVariant > arrivedVariants) visibleVariant = 1; + return true; + } + recoverEmptyCycling(reason); + return false; + } + + function recoverEmptyCycling(reason) { + if (recoveringEmptyCycling) return; + recoveringEmptyCycling = true; + try { + console.warn('[impeccable] Refusing to render empty variant cycling state:', reason); + const message = 'No variants were mounted. Please try again.'; + if (svelteComponentSession?.sessionId === currentSessionId) { + abortSvelteComponentInjection(currentSessionId, message); + return; + } + cleanup(); + showToast(message, 5000); + } finally { + recoveringEmptyCycling = false; + } + } + + // // Params panel (per-variant coarse controls) // // Variants may declare a parameter manifest via a JSON attribute on the @@ -2446,13 +2634,13 @@ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped // CSS can respond instantly without regeneration: // - // range / numeric toggle → CSS var (`--p-`) used via var(--p-foo, N) + // range / numeric toggle -> CSS custom property used by variant styles // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."] // // On variant switch, values reset to that variant's declared defaults. // On accept, current values are sent in the event payload so the agent // can bake them into the source-file write. - // --------------------------------------------------------------------------- + // let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide) let paramsPanelInner = null; // translating content (carries bg, padding, knobs) @@ -2507,7 +2695,7 @@ }); paramsPanelEl.appendChild(paramsPanelBody); - document.body.appendChild(paramsPanelEl); + uiAppend(paramsPanelEl); // Don't override pointer-events: the panel toggles between 'none' (closed, // click-through) and 'auto' (open) on its own. Just silence the host's // outside-interaction listeners while the panel is open. @@ -2516,14 +2704,40 @@ } + function getMountedSvelteComponentAnchor(session = svelteComponentSession) { + const el = session?.mountTargetEl?.firstElementChild || null; + if (!el || !document.body.contains(el)) return null; + return rectIsUsableAnchor(el.getBoundingClientRect()) ? el : null; + } + + function resolveSvelteComponentAnchor(session = svelteComponentSession) { + return getMountedSvelteComponentAnchor(session) + || session?.swapAnchor + || null; + } + function getVisibleVariantEl() { if (!currentSessionId) return null; + if (svelteComponentSession?.sessionId === currentSessionId) { + return resolveSvelteComponentAnchor() + || svelteComponentSession.wrapperEl + || null; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } function parseVariantParams(variantEl) { + // Svelte component variants can't carry a `data-impeccable-params` attribute: + // the compiler reads `{` inside attribute values as expression delimiters, so + // JSON-with-braces breaks the build. For that path the params live in a sidecar + // params.json keyed by variant number, loaded into the session at mount time. + if (svelteComponentSession?.sessionId === currentSessionId) { + const byVariant = svelteComponentSession.paramsByVariant || {}; + const params = byVariant[String(visibleVariant)] || byVariant[visibleVariant]; + return Array.isArray(params) ? params : []; + } if (!variantEl) return []; const raw = variantEl.getAttribute('data-impeccable-params'); if (!raw) return []; @@ -2685,11 +2899,11 @@ } } - // --------------------------------------------------------------------------- + // // Inline text editing - makes pure-text descendants of the picked element // directly contenteditable. Save stages copy edits in the live buffer; the // Apply copy edits dock later asks the AI to apply the staged batch. - // --------------------------------------------------------------------------- + // let inlineEditRows = []; let inlineEditDrafts = new Map(); @@ -2803,7 +3017,7 @@ function disableInlineEdit(opts = {}) { for (const row of inlineEditRows) { - if (document.activeElement === row.el) row.el.blur(); + if (activeElementDeep() === row.el) row.el.blur(); row.el.removeAttribute('contenteditable'); delete row.el.dataset.impeccableEditable; delete row.el.dataset.impeccableOriginalText; @@ -3133,7 +3347,7 @@ if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) { showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); } else { - showToast('Save failed: retry or cancel', 4000); + showToast('Save failed - retry or cancel', 4000); } } } @@ -3181,11 +3395,11 @@ } function ensureSpinKeyframes() { - if (document.getElementById(PREFIX + '-keyframes')) return; + if (uiGetById(PREFIX + '-keyframes')) return; const style = document.createElement('style'); style.id = PREFIX + '-keyframes'; style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }'; - document.head.appendChild(style); + uiAppendStyle(style); } function pendingApplyLabel(count) { @@ -3318,10 +3532,10 @@ closeTunePopover(); } if (barEl && barEl.style.display !== 'none' && state === 'CONFIGURING') { - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value : ''; updateBarContent('configure'); - const nextInput = document.getElementById(PREFIX + '-input'); + const nextInput = uiGetById(PREFIX + '-input'); if (nextInput) nextInput.value = prompt; } if (editBadgeEl && editBadgeEl.style.display !== 'none') { @@ -3455,19 +3669,19 @@ updatePendingCounter(remaining); if (result.failed && result.failed.length > 0) { console.warn('[impeccable] some copy edits failed:', result.failed); - showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed - see console', 5000); } else { const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0); if (n > 0) { showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); } else { console.warn('[impeccable] apply returned no verified edits:', result); - showToast('No edits applied, see console', 4000); + showToast('No edits applied - see console', 4000); } } } catch (err) { console.error('[impeccable] commit failed:', err); - showToast('Apply failed, see console', 4000); + showToast('Apply failed - see console', 4000); } finally { if (waitForSseCompletion) return; const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0; @@ -3497,7 +3711,7 @@ } } catch (err) { console.error('[impeccable] discard failed:', err); - showToast('Discard failed, see console', 4000); + showToast('Discard failed - see console', 4000); } } @@ -3645,7 +3859,7 @@ const failedCount = numberOrNull(msg.failedCount) || 0; const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0; if (failedCount > 0) { - showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000); + showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed - see console', 5000); } else if (appliedCount > 0) { showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500); } @@ -3799,9 +4013,164 @@ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); } - // --------------------------------------------------------------------------- + // // Edit content badge - floating button at element top-right to enter EDITING mode - // --------------------------------------------------------------------------- + // + + function usesShadowChromeRoot() { + const root = liveUiRoot(); + return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; + } + + function setImportantStyle(el, name, value) { + el.style.setProperty(name, value, 'important'); + } + + function initEditBadgeHitProxies() { + if (!usesShadowChromeRoot() || editBadgeProxyRoot) return; + editBadgeProxyRoot = document.createElement('div'); + editBadgeProxyRoot.id = PREFIX + '-edit-badge-hit-proxies'; + editBadgeProxyRoot.setAttribute('aria-hidden', 'true'); + const styles = { + all: 'initial', + position: 'fixed', + inset: '0', + width: '100vw', + height: '100vh', + zIndex: String(Z.toast + 1), + pointerEvents: 'none', + background: 'transparent', + overflow: 'visible', + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + document.body.appendChild(editBadgeProxyRoot); + } + + function styleEditBadgeProxy(proxy, target) { + const rect = target.getBoundingClientRect(); + const cursor = getComputedStyle(target).cursor || 'pointer'; + const styles = { + all: 'initial', + position: 'fixed', + left: rect.left + 'px', + top: rect.top + 'px', + width: rect.width + 'px', + height: rect.height + 'px', + margin: '0', + padding: '0', + border: '0', + borderRadius: '0', + background: 'transparent', + color: 'transparent', + opacity: '0.001', + pointerEvents: 'auto', + cursor, + zIndex: String(Z.toast + 2), + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(proxy, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + } + + function proxyMouseEvent(type, source, target) { + let event; + try { + event = new MouseEvent(type, { + bubbles: type !== 'mouseenter' && type !== 'mouseleave', + cancelable: true, + composed: true, + clientX: source.clientX, + clientY: source.clientY, + screenX: source.screenX, + screenY: source.screenY, + button: source.button || 0, + buttons: source.buttons || 0, + ctrlKey: source.ctrlKey, + metaKey: source.metaKey, + shiftKey: source.shiftKey, + altKey: source.altKey, + }); + target.dispatchEvent(event); + } catch {} + } + + function bindEditBadgeProxy(proxy, target) { + const stop = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + proxy.addEventListener('mouseenter', (event) => { + stop(event); + proxyMouseEvent('mouseenter', event, target); + proxyMouseEvent('mouseover', event, target); + }); + proxy.addEventListener('mouseleave', (event) => { + stop(event); + proxyMouseEvent('mouseleave', event, target); + proxyMouseEvent('mouseout', event, target); + }); + proxy.addEventListener('mousedown', (event) => { + stop(event); + target.focus?.({ preventScroll: true }); + proxyMouseEvent('mousedown', event, target); + }); + proxy.addEventListener('mouseup', (event) => { + stop(event); + proxyMouseEvent('mouseup', event, target); + }); + proxy.addEventListener('click', (event) => { + stop(event); + target.click(); + syncEditBadgeHitProxies(); + }); + } + + function editBadgeProxyTargets() { + if (!usesShadowChromeRoot() || !editBadgeEl || editBadgeEl.style.display === 'none') return []; + return [...editBadgeEl.querySelectorAll('button')].filter((target) => { + if (target.disabled) return false; + const rect = target.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return false; + const style = getComputedStyle(target); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); + } + + function syncEditBadgeHitProxies() { + if (!usesShadowChromeRoot()) { + if (editBadgeProxyRoot) editBadgeProxyRoot.remove(); + editBadgeProxyRoot = null; + editBadgeProxyByTarget = new Map(); + return; + } + initEditBadgeHitProxies(); + if (!editBadgeProxyRoot) return; + const targets = editBadgeProxyTargets(); + const active = new Set(targets); + for (const [target, proxy] of editBadgeProxyByTarget) { + if (!active.has(target) || !target.isConnected) { + proxy.remove(); + editBadgeProxyByTarget.delete(target); + } + } + for (const target of targets) { + let proxy = editBadgeProxyByTarget.get(target); + if (!proxy) { + proxy = document.createElement('button'); + proxy.type = 'button'; + proxy.tabIndex = -1; + proxy.dataset.impeccableEditBadgeProxy = 'true'; + proxy.setAttribute('aria-hidden', 'true'); + bindEditBadgeProxy(proxy, target); + editBadgeProxyRoot.appendChild(proxy); + editBadgeProxyByTarget.set(target, proxy); + } + proxy.title = target.title || target.textContent || 'Edit copy'; + styleEditBadgeProxy(proxy, target); + } + } function initEditBadge() { editBadgeEl = document.createElement('div'); @@ -3813,10 +4182,11 @@ display: 'none', userSelect: 'none', }); - document.body.appendChild(editBadgeEl); + uiAppend(editBadgeEl); + initEditBadgeHitProxies(); // Remove focus rings on edit badge buttons + contenteditable elements - if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + if (!uiGetById(PREFIX + '-edit-badge-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-edit-badge-focus-style'; s.textContent = @@ -3826,21 +4196,26 @@ '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; - document.head.appendChild(s); + uiAppendStyle(s); } } function positionEditBadge() { - if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') { + syncEditBadgeHitProxies(); + return; + } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { if (editBadgeEl) editBadgeEl.style.display = 'none'; + syncEditBadgeHitProxies(); return; } editBadgeEl.style.display = 'flex'; @@ -4047,7 +4422,7 @@ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN; } // Re-render the bar so the Tune chip picks up the active styling. - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } function closeTunePopover() { @@ -4055,13 +4430,13 @@ hideParamsPanel(); if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT; if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') { - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } } - // --------------------------------------------------------------------------- + // // Variant cycling in DOM - // --------------------------------------------------------------------------- + // function isVariantShown(el) { if (!el) return false; @@ -4081,9 +4456,42 @@ } } - function showVariantInDOM(sessionId, num) { + function scheduleCyclingBarSync(sessionId, variantNum) { + requestAnimationFrame(() => { + if (state !== 'CYCLING') return; + if (currentSessionId !== sessionId) return; + if (visibleVariant !== variantNum) return; + showOrUpdateCyclingBar(); + syncCyclingControls(); + positionBar(); + }); + } + + function syncCyclingControls() { + const shown = svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0 + ? svelteComponentSession.mountedVariant + : visibleVariant; + const counter = uiGetById(PREFIX + '-variant-counter'); + if (counter && arrivedVariants > 0) counter.textContent = shown + '/' + arrivedVariants; + const prev = uiGetById(PREFIX + '-variant-prev'); + const next = uiGetById(PREFIX + '-variant-next'); + if (prev) prev.style.opacity = shown <= 1 ? '0.3' : '1'; + if (next) next.style.opacity = shown >= arrivedVariants ? '0.3' : '1'; + if (currentSessionId && state === 'CYCLING') saveSession(); + } + + async function showVariantInDOM(sessionId, num) { + if (svelteComponentSession?.sessionId === sessionId) { + visibleVariant = num; + const mounted = await mountSvelteComponentVariant(num); + if (!mounted) return false; + updateSelectedElement(); + refreshParamsPanel(); + scheduleCyclingBarSync(sessionId, num); + return true; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); - if (!wrapper) return; + if (!wrapper) return false; for (const child of wrapper.children) { const v = child.dataset ? child.dataset.impeccableVariant : null; if (!v) continue; @@ -4093,6 +4501,378 @@ // CYCLING yet, the subsequent CYCLING transition triggers its own // refresh) and every cycle step. refreshParamsPanel(); + return true; + } + + function isSvelteComponentManifestPath(filePath) { + return String(filePath || '').endsWith('manifest.json'); + } + + function parseOriginalMarkupElement(originalMarkup) { + const parser = new DOMParser(); + const doc = parser.parseFromString('
' + originalMarkup + '
', 'text/html'); + return doc.getElementById('impeccable-anchor')?.firstElementChild || null; + } + + function findLiveElementForOriginalMarkup(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const tag = origContent.tagName.toLowerCase(); + const cls = origContent.className; + let liveEl = null; + if (origContent.id) { + liveEl = document.getElementById(origContent.id); + } else if (cls) { + const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + for (const c of candidates) { + if (c.className === cls && !own(c)) { liveEl = c; break; } + } + if (!liveEl) { + const expectedClasses = String(cls).split(/\s+/).filter(Boolean); + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } + } + } + return liveEl; + } + + function isSvelteInsertManifest(manifest) { + return manifest?.previewMode === 'svelte-component' && manifest?.mode === 'insert'; + } + + function findLiveElementForSvelteManifest(manifest) { + if (isSvelteInsertManifest(manifest)) { + const anchor = findInsertAnchorInDom(); + if (anchor?.parentElement) return anchor; + } + return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function loadSvelteRuntime(runtimeModule) { + const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js'; + const url = new URL(modulePath, location.origin).href; + if (!svelteRuntimePromise) { + svelteRuntimePromise = import(/* @vite-ignore */ url); + } + return svelteRuntimePromise; + } + + // Svelte component variants declare their params in a sidecar params.json under + // componentDir (keyed by variant number), because a `data-impeccable-params` + // attribute with JSON braces can't survive the Svelte compiler. Returns a map of + // { "1": [...params], "2": [...] }; an empty object when the agent declared none. + async function loadSvelteComponentParams(manifest) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir) return {}; + const paramsPath = dir + '/params.json'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(paramsPath); + try { + const res = await fetch(url); + if (!res.ok) return {}; + const parsed = JSON.parse(await res.text()); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out = {}; + for (const [key, value] of Object.entries(parsed)) { + if (Array.isArray(value)) out[String(key)] = value; + } + return out; + } catch { + return {}; + } + } + + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { + const contract = manifest?.propContract || []; + const values = {}; + if (!liveEl || contract.length === 0) return values; + const sourceOriginal = parseOriginalMarkupElement(manifest.originalMarkup || ''); + if (!sourceOriginal) return values; + const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl); + for (const entry of contract) { + const token = '{' + entry.expr + '}'; + values[entry.prop] = map.get(token) || ''; + } + return values; + } + + async function mountSvelteComponentVariant(variantNum) { + if (!svelteComponentSession || !variantNum) return false; + const { manifest, mountTargetEl, sessionId } = svelteComponentSession; + try { + const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement; + svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null; + const runtime = await loadSvelteRuntime(manifest.runtimeModule); + const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte'; + const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now(); + const mod = await import(/* @vite-ignore */ moduleUrl); + const Component = mod.default; + if (svelteComponentSession.mountedInstance && runtime.unmount) { + await runtime.unmount(svelteComponentSession.mountedInstance); + svelteComponentSession.mountedInstance = null; + } + svelteComponentSession.mountedInstance = runtime.mount(Component, { + target: mountTargetEl, + props: { ...svelteComponentSession.propValues }, + intro: false, + }); + svelteComponentSession.mountedVariant = variantNum; + svelteComponentSession.runtime = runtime; + if (state === 'CYCLING') syncCyclingControls(); + const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (nextAnchor) { + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(nextAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = nextAnchor; + } else { + requestAnimationFrame(() => { + if (svelteComponentSession?.sessionId !== sessionId) return; + const settledAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!settledAnchor) return; + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(settledAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = settledAnchor; + }); + } + return true; + } catch (err) { + if (svelteComponentSession?.sessionId === sessionId) { + svelteComponentSession.swapAnchor = null; + } + console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err); + return false; + } + } + + function teardownSvelteComponentSession(restoreOriginal) { + if (!svelteComponentSession) return; + const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + if (restoreOriginal && detachedOriginal && wrapperEl?.parentElement) { + wrapperEl.parentElement.replaceChild(detachedOriginal, wrapperEl); + } else if (wrapperEl?.parentElement) { + wrapperEl.remove(); + } + svelteComponentSession = null; + svelteRuntimePromise = null; + } + + function applyOriginalAttrsToSvelteAnchor(el, originalMarkup) { + if (!el || !originalMarkup) return; + const original = parseOriginalMarkupElement(originalMarkup); + if (!original || original.tagName !== el.tagName) return; + for (const attr of original.attributes) { + if (attr.name === 'class') { + for (const className of attr.value.split(/\s+/).filter(Boolean)) { + el.classList.add(className); + } + } else if (!el.hasAttribute(attr.name)) { + el.setAttribute(attr.name, attr.value); + } + } + } + + function commitAcceptedSvelteComponentToDom(sessionId) { + if (!svelteComponentSession || svelteComponentSession.sessionId !== sessionId) return false; + const { wrapperEl, runtime, mountedInstance, manifest } = svelteComponentSession; + const anchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!anchor || !wrapperEl?.parentElement) return false; + const committed = anchor.cloneNode(true); + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(committed, manifest.originalMarkup || ''); + } + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + wrapperEl.parentElement.replaceChild(committed, wrapperEl); + svelteComponentSession = null; + svelteRuntimePromise = null; + selectedElement = committed; + return true; + } + + async function injectSvelteComponentsFromManifest(manifestPath, sessionId) { + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(manifestPath); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + const manifest = JSON.parse(await res.text()); + if (manifest.id !== sessionId) return; + + const paramsByVariant = await loadSvelteComponentParams(manifest); + currentSessionId = sessionId; + expectedVariants = Number(manifest.count) || expectedVariants || 1; + rememberSessionFileMeta({ + sourceFile: manifest.sourceFile, + previewFile: manifestPath, + previewMode: 'svelte-component', + }); + if (state !== 'CYCLING') state = 'GENERATING'; + + const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { + recoveryWaitingForAnchor = false; + svelteComponentSession.paramsByVariant = paramsByVariant; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; + await mountSvelteComponentVariant(visibleVariant || 1); + state = 'CYCLING'; + showOrUpdateCyclingBar(); + saveSession(); + return; + } + + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) { + console.warn('[impeccable] Could not find original element in live DOM.'); + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants + ? visibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + saveSession(); + queueCheckpoint('svelte_component_anchor_missing'); + waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return; + } + + const wrapper = document.createElement('div'); + wrapper.dataset.impeccableVariants = sessionId; + wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1); + wrapper.dataset.impeccablePreview = 'svelte-component'; + wrapper.style.display = 'contents'; + + const mountTarget = document.createElement('div'); + mountTarget.dataset.impeccableComponentMount = sessionId; + mountTarget.style.display = 'contents'; + wrapper.appendChild(mountTarget); + + const insertMode = isSvelteInsertManifest(manifest); + const detachedOriginal = insertMode ? null : liveEl; + if (insertMode) { + removeInsertPlaceholderDom(); + if (manifest.position === 'before') liveEl.parentElement.insertBefore(wrapper, liveEl); + else liveEl.parentElement.insertBefore(wrapper, liveEl.nextSibling); + } else { + liveEl.parentElement.replaceChild(wrapper, liveEl); + } + + svelteComponentSession = { + sessionId, + manifest, + insertMode, + wrapperEl: wrapper, + mountTargetEl: mountTarget, + detachedOriginal, + mountedInstance: null, + mountedVariant: 0, + runtime: null, + propValues: buildSveltePropValuesFromLiveElement(detachedOriginal, manifest), + paramsByVariant, + }; + if (pendingSvelteComponentRetryObserver) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + } + recoveryWaitingForAnchor = false; + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + + const mounted = await mountSvelteComponentVariant(visibleVariant); + if (!mounted) { + // The compiled component threw (e.g. a Svelte compile error in the + // variant file). Don't strand the bar in an empty CYCLING state; restore + // the original element and reset to PICKING so the user can retry. + abortSvelteComponentInjection(sessionId, 'A variant failed to compile. Fix the component and re-run.'); + return; + } + + selectedElement = mountTarget.firstElementChild || mountTarget; + state = 'CYCLING'; + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.'); + } catch (err) { + console.error('[impeccable] Failed to mount Svelte component variants:', err); + abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.'); + } + } + + function waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }) { + if (pendingSvelteComponentRetryObserver) pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = new MutationObserver(() => { + if (svelteComponentSession?.sessionId === sessionId) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + return; + } + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) return; + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + injectSvelteComponentsFromManifest(manifestPath, sessionId); + }); + pendingSvelteComponentRetryObserver.observe(document.body, { childList: true, subtree: true }); + } + + // Reset cleanly when a Svelte component session can't mount: tear the wrapper + // down (restoring the original element), clear persisted session state, and + // return the bar to PICKING. Avoids the stuck 0/0 CYCLING bar. + function abortSvelteComponentInjection(sessionId, message) { + try { + if (svelteComponentSession?.sessionId === sessionId) { + teardownSvelteComponentSession(true); + } else { + const orphan = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (orphan) orphan.remove(); + } + } catch (err) { + console.warn('[impeccable] Svelte component abort cleanup failed:', err); + } + hideShaderOverlay(); + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + stopScrollLock(); + clearSession(); + clearHandled(); + resetSessionFileMeta(); + currentSessionId = null; + expectedVariants = 0; + arrivedVariants = 0; + visibleVariant = 0; + selectedElement = null; + state = 'PICKING'; + hideBar(); + if (message) showToast(message, 5000); } /** @@ -4101,6 +4881,11 @@ * This works even when the dev server caches HTML (Bun, static servers). */ function injectVariantsFromSource(filePath, sessionId) { + if (isSvelteComponentManifestPath(filePath)) { + injectSvelteComponentsFromManifest(filePath, sessionId); + return; + } + rememberSessionFileMeta({ file: filePath }); const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) @@ -4119,7 +4904,7 @@ const doc = parser.parseFromString(block, 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { - console.error('[impeccable] Variant wrapper not found in source file.'); + console.warn('[impeccable] Variant wrapper not found in source file.'); return; } @@ -4134,31 +4919,31 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; - if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - } - + const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); if (!liveEl) { - console.error('[impeccable] Could not find original element in live DOM.'); + console.warn('[impeccable] Could not find original element in live DOM.'); + selectedElement = document.body; + recoveryWaitingForAnchor = true; + state = 'GENERATING'; + showBar('generating'); + saveSession(); + showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } + recoveryWaitingForAnchor = false; // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); arrivedVariants = variants.length; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + recoverEmptyCycling('source-fallback-empty'); + return; + } const saved = loadSession(); const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants @@ -4170,8 +4955,9 @@ selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4184,21 +4970,129 @@ }); } - function cycleVariant(dir) { + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { + const map = new Map(); + if (!sourceOriginal || !liveOriginal) return map; + + const sourceNodes = collectTextNodes(sourceOriginal) + .filter((node) => /\{[^{}]+\}/.test(node.nodeValue || '')); + const liveTexts = collectTextNodes(liveOriginal) + .map((node) => normalizePreviewText(node.nodeValue || '')) + .filter(Boolean); + let liveIndex = 0; + + for (const sourceNode of sourceNodes) { + const sourceText = sourceNode.nodeValue || ''; + const tokens = sourceText.match(/\{[^{}]+\}/g) || []; + if (tokens.length === 0) continue; + + const liveText = liveTexts[liveIndex++] || ''; + if (!liveText) continue; + + if (tokens.length === 1) { + const token = tokens[0]; + const normalizedSource = normalizePreviewText(sourceText); + if (normalizedSource === token) { + map.set(token, liveText); + continue; + } + + const match = liveText.match(expressionTextMatcher(sourceText, [token])); + if (match && match[1]) map.set(token, match[1].trim()); + continue; + } + + if (normalizePreviewText(sourceText) === tokens.join(' ')) { + for (const token of tokens) { + const tokenLiveText = liveTexts[liveIndex - 1] || ''; + if (tokenLiveText) map.set(token, tokenLiveText); + } + } + } + + return map; + } + + function expressionTextMatcher(sourceText, tokens) { + let pattern = '^'; + let cursor = 0; + for (const token of tokens) { + const index = sourceText.indexOf(token, cursor); + if (index === -1) continue; + pattern += escapeRegExp(sourceText.slice(cursor, index)).replace(/\s+/g, '\\s*'); + pattern += '(.*?)'; + cursor = index + token.length; + } + pattern += escapeRegExp(sourceText.slice(cursor)).replace(/\s+/g, '\\s*') + '$'; + return new RegExp(pattern); + } + + function collectTextNodes(root) { + if (!root) return []; + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + nodes.push(node); + node = walker.nextNode(); + } + return nodes; + } + + function normalizePreviewText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + async function selectVariant(next, checkpointReason) { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - const next = visibleVariant + dir; + if (variantSelectionInFlight) return; if (next < 1 || next > arrivedVariants) return; - visibleVariant = next; - showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself - updateSelectedElement(); - updateBarContent('cycling'); - positionBar(); - saveSession(); - queueCheckpoint('variant_changed'); + if (next === visibleVariant) return; + + const previous = visibleVariant; + variantSelectionInFlight = true; + const selectionPromise = (async () => { + visibleVariant = next; + showOrUpdateCyclingBar(); + saveSession(); + const shown = await showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself + if (!shown) { + visibleVariant = previous; + await showVariantInDOM(currentSessionId, previous); + showOrUpdateCyclingBar(); + saveSession(); + return; + } + updateSelectedElement(); + showOrUpdateCyclingBar(); + positionBar(); + saveSession(); + if (checkpointReason) queueCheckpoint(checkpointReason); + })(); + variantSelectionPromise = selectionPromise; + try { + await selectionPromise; + } finally { + if (variantSelectionPromise === selectionPromise) variantSelectionPromise = null; + variantSelectionInFlight = false; + } + } + + function cycleVariant(dir) { + selectVariant(visibleVariant + dir, 'variant_changed'); } function updateSelectedElement() { if (!currentSessionId) return; + if (svelteComponentSession?.sessionId === currentSessionId) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; + return; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); @@ -4206,6 +5100,9 @@ } function readVisibleVariantFromDOM(sessionId) { + if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { + return svelteComponentSession.mountedVariant; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -4341,9 +5238,9 @@ // scrollY that the next resume needs to read. } - // --------------------------------------------------------------------------- + // // MutationObserver for progressive variant reveal - // --------------------------------------------------------------------------- + // function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -4426,10 +5323,11 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); updateSelectedElement(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4445,9 +5343,9 @@ return obs; } - // --------------------------------------------------------------------------- + // // Bar scroll tracking - // --------------------------------------------------------------------------- + // function startScrollTracking() { function tick() { @@ -4483,10 +5381,10 @@ if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; } } - // --------------------------------------------------------------------------- + // // SSE (server→browser) + fetch POST (browser→server) // Zero-dependency replacement for WebSocket. - // --------------------------------------------------------------------------- + // let evtSource = null; let sseRetries = 0; @@ -4509,6 +5407,7 @@ console.log('[impeccable] Live mode connected.'); syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); + restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; syncPageChatFocus('sse-connected'); break; @@ -4531,11 +5430,12 @@ break; case 'done': if (maybeCompleteSteer(msg)) break; + rememberSessionFileMeta(msg); // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); } @@ -4557,7 +5457,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; if (state !== 'GENERATING') return; showToast( - "Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.", + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", 15000, ); }, 2000); @@ -4571,12 +5471,18 @@ // the final complete event. Keep the browser in its recoverable // saving state while the source cleanup is still in flight. break; + case 'discarded': + if (msg.id && msg.id === currentSessionId) { + markSessionHandled(); + cleanup(); + } + break; case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; state = 'CYCLING'; updateBarContent('cycling'); - showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000); + showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; } if (maybeCompleteSteer(msg)) break; @@ -4656,6 +5562,9 @@ expectedVariants, arrivedVariants, visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, paramValues: { ...paramsCurrentValues }, }; } @@ -4665,6 +5574,20 @@ return sendEvent(checkpointPayload(reason)).catch(() => null); } + function sendSteerCheckpoint(id, reason, extra) { + if (!id) return Promise.resolve(null); + return sendEvent({ + type: 'checkpoint', + id, + revision: sessionState.nextCheckpointRevision(), + owner: browserOwner, + phase: 'steer', + reason, + pageUrl: location.pathname, + ...(extra || {}), + }).catch(() => null); + } + function queueCheckpoint(reason) { if (!currentSessionId) return; if (checkpointTimer) clearTimeout(checkpointTimer); @@ -4674,9 +5597,9 @@ }, 120); } - // --------------------------------------------------------------------------- + // // Event handlers - // --------------------------------------------------------------------------- + // function handleMouseMove(e) { if (pendingApplyInFlight) return; @@ -4860,7 +5783,7 @@ // // DISABLED: quick-Go workflows pay an extra harness round trip because // prefetch + generate arrive as two events instead of one. Re-enable with - // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to + // a browser-side debounce (~800-1000ms, cancelled on Go) if we want to // resurrect this. Server validator and skill dispatch remain in place so // flipping this flag is the only change needed. const PREFETCH_ENABLED = false; @@ -4876,6 +5799,14 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + const deepActive = activeElementDeep(); + if ( + deepActive + && own(deepActive) + && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + ) { + return; + } // While a contenteditable text-leaf is focused, let the browser handle // all keys except Escape. Escape cancels the current edit (restores // original text) and blurs without saving, staying in CONFIGURING. @@ -4982,7 +5913,7 @@ if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!selectedElement || state !== 'CONFIGURING') return; stopVoice({ suppressSubmit: true }); - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; // Commit any pending pin edit BEFORE we snapshot annotations. @@ -4996,6 +5927,7 @@ expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); // Flip to GENERATING immediately so the bar morphs without waiting on // capture + upload. The event is emitted from captureAndEmit() once the @@ -5054,7 +5986,7 @@ function handleInsertCreate() { if (!placeholderElement || !insertAnchorElement || state !== 'CONFIGURING' || configureKind !== 'insert') return; - const input = document.getElementById(PREFIX + '-insert-input'); + const input = uiGetById(PREFIX + '-insert-input'); const prompt = input ? input.value.trim() : ''; if (annotEditing) finalizeEditingPin(); const snapshot = { @@ -5064,10 +5996,12 @@ if (!canCreateInsert({ prompt, comments: snapshot.comments, strokes: snapshot.strokes })) return; stopVoice({ suppressSubmit: true }); + pendingAcceptedSession = null; currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); selectedElement = placeholderElement; insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement); @@ -5107,9 +6041,9 @@ captureAndEmit(elForCapture, basePayload, snapshot, captureRect); } - // --------------------------------------------------------------------------- + // // Screenshot capture + upload - // --------------------------------------------------------------------------- + // let msLoadPromise = null; function loadModernScreenshot() { @@ -5120,7 +6054,7 @@ s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; s.onload = () => resolve(window.modernScreenshot); s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; - document.head.appendChild(s); + uiAppendStyle(s); }); return msLoadPromise; } @@ -5235,11 +6169,113 @@ return '#ffffff'; } + function captureChromeNodes() { + const nodes = []; + const add = (node) => { + if (!node || node === document.body || nodes.includes(node)) return; + nodes.push(node); + }; + add(document.getElementById(PREFIX + '-root')); + [ + PREFIX + '-highlight', + PREFIX + '-tooltip', + PREFIX + '-bar', + PREFIX + '-picker', + PREFIX + '-params-panel', + PREFIX + '-insert-line', + PREFIX + '-insert-placeholder', + PREFIX + '-insert-create-tooltip', + PREFIX + '-annot', + PREFIX + '-design-host', + PREFIX + '-toast', + PREFIX + '-shader', + ].forEach((id) => add(uiGetById(id))); + return nodes; + } + + async function hideCaptureChromeForShaderProxy(fn) { + const saved = captureChromeNodes().map((node) => ({ + node, + visibility: node.style.visibility, + priority: node.style.getPropertyPriority('visibility'), + })); + for (const { node } of saved) { + node.style.setProperty('visibility', 'hidden', 'important'); + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + try { + return await fn(); + } finally { + for (const { node, visibility, priority } of saved) { + node.style.setProperty('visibility', visibility, priority); + } + } + } + + function shouldUseAncestorCropShaderProxy(el) { + // TODO: Enable this proxy for React/Vue/etc. adapters once their live + // preview mounts are covered by the same shader regression checks. + const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase(); + if (adapter === 'svelte' || adapter === 'sveltekit') return true; + if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true; + const wrapper = el?.closest?.('[data-impeccable-variants]'); + return wrapper?.dataset?.impeccablePreview === 'svelte-component'; + } + + function paintsShaderProxySurface(node) { + const s = getComputedStyle(node); + return !isTransparentColor(s.backgroundColor) + || (s.backgroundImage && s.backgroundImage !== 'none') + || paintsBackdrop(node); + } + + function findShaderProxyCaptureRoot(el) { + const doc = el.ownerDocument || document; + const er = el.getBoundingClientRect(); + let node = el.parentElement; + while (node && node !== doc.documentElement) { + const nr = node.getBoundingClientRect(); + const containsElement = + nr.width > 0 && nr.height > 0 && + nr.left <= er.left + 0.5 && + nr.top <= er.top + 0.5 && + nr.right >= er.right - 0.5 && + nr.bottom >= er.bottom - 0.5; + if (containsElement && paintsShaderProxySurface(node)) return node; + node = node.parentElement; + } + return null; + } + // Capture the element (with current annotations baked in) and return // { blob, paper }: the PNG Blob, plus the representative backdrop tone for the // shader's halftone ground (so capture, upload, and shader all agree on what // sits behind the element). Shared between the Go flow (uploads the blob) and // the shader-resume path. + async function captureElementFromRenderedAncestor(ms, el, opts) { + const doc = el.ownerDocument || document; + const captureRoot = findShaderProxyCaptureRoot(el); + if (!captureRoot) throw new Error('No painted ancestor for Svelte shader proxy'); + const rootCanvas = await ms.domToCanvas(captureRoot, opts); + const S = opts.scale; + const er = el.getBoundingClientRect(); + const rr = captureRoot.getBoundingClientRect(); + const sx = (er.left - rr.left) * S; + const sy = (er.top - rr.top) * S; + const sw = er.width * S; + const sh = er.height * S; + if (sw <= 0 || sh <= 0) throw new Error('Selected element has no visible capture rect'); + const crop = doc.createElement('canvas'); + crop.width = Math.max(1, Math.round(sw)); + crop.height = Math.max(1, Math.round(sh)); + const cctx = crop.getContext('2d', { willReadFrequently: true }); + cctx.drawImage(rootCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height); + const paper = dominantRgb01(cctx, crop.width, crop.height) || averageRgb01(cctx, crop.width, crop.height); + const blob = await new Promise((res) => crop.toBlob(res, 'image/png')); + if (!blob) throw new Error('Ancestor crop failed to produce a PNG blob'); + return { blob, paper }; + } + async function captureElementToBlob(el, snapshot, rect) { try { if (document.fonts?.ready) await document.fonts.ready; } catch {} const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); @@ -5261,6 +6297,13 @@ scale: Math.min(window.devicePixelRatio || 1, 2), font: fontCssText ? { cssText: fontCssText } : undefined, }; + if (shouldUseAncestorCropShaderProxy(el)) { + try { + return await hideCaptureChromeForShaderProxy(() => captureElementFromRenderedAncestor(ms, el, opts)); + } catch (err) { + console.warn('[impeccable] Svelte ancestor crop capture failed, falling back to element capture:', err); + } + } const bg = resolveCanvasBackground(el); // Fast path: the element paints its own background, or an opaque ancestor // color was found. modern-screenshot bakes that color; paper matches it. @@ -5342,13 +6385,13 @@ sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); } - // --------------------------------------------------------------------------- + // // Shader overlay - renders the captured screenshot as a WebGL texture and // runs an editorial "ink-wash" fragment shader over it during generation. // A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku // and leaving a soft trail. Makes the wait feel like a letterpress scan // instead of a dead spinner. - // --------------------------------------------------------------------------- + // const SHADER_VS = `attribute vec2 a_position; attribute vec2 a_uv; @@ -5502,6 +6545,31 @@ void main() { return n ? [r / n / 255, g / n / 255, b / n / 255] : SHADER_PAPER_FALLBACK; } + // Pick the most common visible color cluster from a crop. A straight average + // gets pulled by text and icons; the dominant bucket usually represents the + // surface the shader should dissolve into. + function dominantRgb01(ctx, w, h) { + const data = ctx.getImageData(0, 0, w, h).data; + const stride = Math.max(1, Math.floor((w * h) / 6000)); + const buckets = new Map(); + for (let p = 0; p < w * h; p += stride) { + const i = p * 4; + if (data[i + 3] < 16) continue; + const key = (data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4); + const bucket = buckets.get(key) || { count: 0, r: 0, g: 0, b: 0 }; + bucket.count += 1; + bucket.r += data[i]; + bucket.g += data[i + 1]; + bucket.b += data[i + 2]; + buckets.set(key, bucket); + } + let best = null; + for (const bucket of buckets.values()) { + if (!best || bucket.count > best.count) best = bucket; + } + return best ? [best.r / best.count / 255, best.g / best.count / 255, best.b / best.count / 255] : null; + } + // Average the backdrop sampled just OUTSIDE an element's rect within a larger // canvas. The ground tone for the dissolve must be the real backdrop, not the // mean of the element's own crop - averaging the crop folds in the element's @@ -5572,7 +6640,7 @@ void main() { fallback.style.backgroundRepeat = 'no-repeat'; fallback.style.outline = '2px dashed ' + C.brand; fallback.style.outlineOffset = '-2px'; - document.body.appendChild(fallback); + uiAppend(fallback); shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl }; } @@ -5582,16 +6650,19 @@ void main() { const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); + const radius = getComputedStyle(el).borderRadius; canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); Object.assign(canvas.style, { position: 'fixed', top: rect.top + 'px', left: rect.left + 'px', width: rect.width + 'px', height: rect.height + 'px', + borderRadius: radius, + overflow: 'hidden', pointerEvents: 'none', zIndex: Z.bar - 1, }); - document.body.appendChild(canvas); + uiAppend(canvas); const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) || canvas.getContext('experimental-webgl'); @@ -5685,8 +6756,12 @@ void main() { frame(); } - function handleAccept() { + async function handleAccept() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } + if (pendingAcceptedSession || state === 'SAVING') return; + if (variantSelectionPromise) { + try { await variantSelectionPromise; } catch { /* failed selection falls back below */ } + } if (!currentSessionId || arrivedVariants === 0) return; const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId); if (domVisibleVariant > 0) visibleVariant = domVisibleVariant; @@ -5696,30 +6771,39 @@ void main() { variantId: String(visibleVariant), pageUrl: location.pathname, }; + const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } + // The accepted variant is already the only visible child of the wrapper + // (all other variants are display:none). HMR from the source rewrite will + // replace the wrapper imminently. Don't eagerly replaceChild here - React + // reconciliation races with our mutation and throws NotFoundError in Next + // 16 / Turbopack. Schedule a fallback that runs the manual swap only if + // HMR hasn't cleaned up by then (keeps static-server flows working). const acceptedSessionId = currentSessionId; const acceptedVariant = visibleVariant; + const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId + || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - pendingAcceptedSession = { - id: acceptedSessionId, - variant: String(acceptedVariant), - ...acceptedSnapshot, - finalizing: false, - }; state = 'SAVING'; updateBarContent('saving'); + pendingAcceptedSession = { + id: acceptedSessionId, + variant: String(acceptedVariant), + isSvelteComponent: acceptedIsSvelteComponent, + ...acceptedSnapshot, + finalizing: false, + }; + saveSession(); sendEvent(acceptPayload, { throwOnError: true }) - .then(() => { - markSessionHandled(); - }) + .then(() => {}) .catch(() => { - pendingAcceptedSession = null; + if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); } @@ -5733,19 +6817,21 @@ void main() { } if (pending.finalizing) return true; pending.finalizing = true; - + markSessionHandled(); + if (pending.isSvelteComponent) { + commitAcceptedSvelteComponentToDom(pending.id); + } state = 'CONFIRMED'; updateBarContent('confirmed'); + scheduleAcceptCleanup(pending); + return true; + } - // Give framework HMR a short chance to render the now-clean accepted - // source. If it misses the update, unwrap the accepted variant after the - // source-side completion event so the page is not left empty or stale. + function scheduleAcceptCleanup(accepted) { setTimeout(function() { - ensureAcceptedDomClean(pending); + if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted); cleanupAcceptedSession(); }, 1200); - - return true; } function snapshotAcceptedVariantDom(sessionId, variantId) { @@ -5833,6 +6919,7 @@ void main() { stopScrollLock(); clearScrollY(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5841,6 +6928,28 @@ void main() { state = 'PICKING'; } + function commitAcceptedVariantToDom(sessionId, variantId) { + const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapper) return false; + const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); + if (!accepted || !accepted.firstElementChild) return false; + const parent = wrapper.parentElement; + if (!parent) return false; + + const style = wrapper.querySelector('style[data-impeccable-css]'); + if (style && !document.querySelector('style[data-impeccable-accepted-css="' + sessionId + '"]')) { + const promotedStyle = style.cloneNode(true); + promotedStyle.setAttribute('data-impeccable-accepted-css', sessionId); + parent.insertBefore(promotedStyle, wrapper); + } + + const committed = accepted.cloneNode(true); + committed.removeAttribute('hidden'); + committed.style.display = 'contents'; + parent.replaceChild(committed, wrapper); + return true; + } + function handleDiscard() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!currentSessionId) return; @@ -5852,11 +6961,141 @@ void main() { .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000)); } - // --------------------------------------------------------------------------- + // // Session persistence via live-browser-session.js - // --------------------------------------------------------------------------- + // // Survives page reloads, browser close/reopen, HMR, and accidental refreshes. + function normalizeSessionPath(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed.replace(/\\/g, '/') : null; + } + + function resetSessionFileMeta() { + currentSourceFile = null; + currentPreviewFile = null; + currentPreviewMode = null; + recoveryWaitingForAnchor = false; + } + + function rememberSessionFileMeta(meta = {}) { + const file = normalizeSessionPath(meta.file); + const sourceFile = normalizeSessionPath(meta.sourceFile); + const previewFile = normalizeSessionPath(meta.previewFile); + const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null); + + if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) { + currentPreviewMode = 'svelte-component'; + currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile); + currentSourceFile = sourceFile || currentSourceFile; + return; + } + + if (sourceFile || file) currentSourceFile = sourceFile || file; + if (previewFile) currentPreviewFile = previewFile; + if (previewMode) currentPreviewMode = previewMode; + } + + function applySavedSessionMeta(saved) { + if (!saved) return; + rememberSessionFileMeta(saved); + if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.action) selectedAction = saved.action; + if (saved.count) selectedCount = saved.count; + if (saved.previewMode) currentPreviewMode = saved.previewMode; + if (saved.paramValues && typeof saved.paramValues === 'object') { + paramsCurrentValues = { ...saved.paramValues }; + } + } + + function normalizePagePath(value) { + if (!value || typeof value !== 'string') return null; + try { + return new URL(value, location.origin).pathname; + } catch { + return value.split(/[?#]/)[0] || null; + } + } + + function pageMatchesCurrent(value) { + const path = normalizePagePath(value); + return !path || path === location.pathname; + } + + function isTerminalSessionSummary(session) { + return /^(completed|discarded|discard_requested|accept_requested)$/.test(String(session?.phase || '')); + } + + function findActiveSessionSummary(saved, activeSessions) { + if (!saved?.id || !Array.isArray(activeSessions)) return null; + return activeSessions.find((session) => + session?.id === saved.id + && pageMatchesCurrent(session.pageUrl || saved.pageUrl) + && !isTerminalSessionSummary(session) + ) || null; + } + + function clampVariantIndex(value, count) { + const num = Number(value); + const max = Number(count); + if (!Number.isFinite(num) || num < 1) return 0; + if (Number.isFinite(max) && max > 0 && num > max) return 0; + return Math.floor(num); + } + + function restoreSessionWithoutWrapper(reason, activeSessions) { + const saved = loadSession(); + if (!saved?.id || isSessionHandled(saved.id)) return false; + const savedState = String(saved.state || '').toUpperCase(); + if (savedState !== 'GENERATING' && savedState !== 'CYCLING') return false; + + const serverSession = findActiveSessionSummary(saved, activeSessions); + if (Array.isArray(activeSessions) && activeSessions.length > 0 && !serverSession) { + return false; + } + + currentSessionId = saved.id; + applySavedSessionMeta(serverSession); + applySavedSessionMeta(saved); + + expectedVariants = Number(saved.expected || serverSession?.expectedVariants || selectedCount || 0); + arrivedVariants = Number(saved.arrived || serverSession?.arrivedVariants || 0); + if (arrivedVariants <= 0 && currentPreviewFile) arrivedVariants = Number(serverSession?.expectedVariants || saved.expected || selectedCount || 0); + if (expectedVariants <= 0) expectedVariants = Number(serverSession?.expectedVariants || arrivedVariants || selectedCount || 0); + visibleVariant = clampVariantIndex(saved.visible, arrivedVariants || expectedVariants) + || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) + || (arrivedVariants > 0 ? 1 : 0); + + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + if (variantObserver) variantObserver.disconnect(); + variantObserver = startVariantObserver(currentSessionId); + saveSession(); + queueCheckpoint(reason || 'browser_restore_without_wrapper'); + + const restoreFile = currentPreviewMode === 'svelte-component' + ? currentPreviewFile + : (currentSourceFile || currentPreviewFile); + if (restoreFile) { + injectVariantsFromSource(restoreFile, currentSessionId); + return true; + } + + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return true; + } + + function restoreFromActiveSessions(activeSessions, reason) { + const wrapper = document.querySelector('[data-impeccable-variants]'); + if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false; + if (svelteComponentSession?.sessionId === currentSessionId) return false; + return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); + } + function saveSession() { if (!currentSessionId) return; // NOTE: scrollY is stored under a separate key (writeScrollY). Storing @@ -5869,6 +7108,11 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, + pageUrl: location.pathname, + paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, }); } @@ -5898,31 +7142,33 @@ void main() { } function cleanup() { - // Hide the wrapper immediately so variants disappear. DON'T structurally - // mutate the DOM yet - HMR from the agent's source rewrite is on its way, - // and a manual replaceChild under React causes NotFoundError when the - // reconciler later tries to remove a wrapper we already removed. - // Schedule a 2s fallback that does the manual swap only if HMR hasn't - // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const cleanupSessionId = currentSessionId; - if (cleanupSessionId) { + if (svelteComponentSession?.sessionId === cleanupSessionId) { + teardownSvelteComponentSession(true); + } else if (cleanupSessionId) { + // Hide the wrapper immediately so variants disappear. DON'T structurally + // mutate the DOM yet - HMR from the agent's source rewrite is on its way, + // and a manual replaceChild under React causes NotFoundError when the + // reconciler later tries to remove a wrapper we already removed. + // Schedule a 2s fallback that does the manual swap only if HMR hasn't + // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); if (wrapper) wrapper.style.display = 'none'; - } - setTimeout(function() { - if (!cleanupSessionId) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!wrapper) return; - const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - wrapper.parentElement.replaceChild(content, wrapper); - return; + setTimeout(function() { + if (!cleanupSessionId) return; + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) return; + const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); + if (orig) { + const content = orig.firstElementChild; + if (content) { + lateWrapper.parentElement.replaceChild(content, lateWrapper); + return; + } } - } - wrapper.remove(); - }, 2000); + lateWrapper.remove(); + }, 2000); + } hideBar(); hideHighlight(); stopScrollTracking(); @@ -5931,6 +7177,7 @@ void main() { clearScrollY(); finalizeInsertSession(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5938,9 +7185,9 @@ void main() { state = 'PICKING'; } - // --------------------------------------------------------------------------- + // // Toast - // --------------------------------------------------------------------------- + // function showToast(message, duration) { if (toastEl) toastEl.remove(); @@ -5964,7 +7211,7 @@ void main() { }); toastEl.id = PREFIX + '-toast'; toastEl.textContent = message; - document.body.appendChild(toastEl); + uiAppend(toastEl); requestAnimationFrame(() => { toastEl.style.opacity = '1'; toastEl.style.transform = 'translateX(-50%) translateY(0)'; @@ -5978,22 +7225,70 @@ void main() { }, duration); } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. function resumeSession() { const wrapper = document.querySelector('[data-impeccable-variants]'); - if (!wrapper) { clearSession(); clearHandled(); return false; } + if (!wrapper) { + if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } const sessionId = wrapper.dataset.impeccableVariants; // Don't resume if this session was already accepted/discarded if (isSessionHandled(sessionId)) return false; + // Svelte component sessions can't be resumed by counting DOM children: the + // wrapper holds a single mount target, not [data-impeccable-variant] nodes, + // and a page reload unmounts every compiled variant. Counting children here + // would strand the bar in CYCLING at 0/0. If there's no live in-memory mount + // for this wrapper, it's an orphan (reload / failed mount): drop it and let + // the live-server's SSE re-inject the manifest if the session is still live. + if (wrapper.dataset.impeccablePreview === 'svelte-component' + && svelteComponentSession?.sessionId !== sessionId) { + wrapper.remove(); + if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } + + if (wrapper.dataset.impeccablePreview === 'svelte-component') { + if (!svelteComponentSession?.mountedVariant) { + return true; + } + currentSessionId = sessionId; + expectedVariants = Number(wrapper.dataset.impeccableVariantCount) + || Number(svelteComponentSession.manifest?.count) + || expectedVariants + || 1; + arrivedVariants = expectedVariants; + const saved = loadSession(); + applySavedSessionMeta(saved); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = svelteComponentSession.mountedVariant > 0 && svelteComponentSession.mountedVariant <= arrivedVariants + ? svelteComponentSession.mountedVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = resolveSvelteComponentAnchor() + || wrapper.parentElement; + state = 'CYCLING'; + hideShaderOverlay(); + showBar('cycling'); + startScrollTracking(); + refreshParamsPanel(); + saveSession(); + queueCheckpoint('browser_resumed_svelte_component'); + return true; + } + currentSessionId = sessionId; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -6002,6 +7297,7 @@ void main() { // Restore state from localStorage if available const saved = loadSession(); if (saved && saved.id === sessionId) { + applySavedSessionMeta(saved); visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0); if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; @@ -6072,9 +7368,9 @@ void main() { return true; } - // --------------------------------------------------------------------------- + // // Global bar (always visible at bottom) - // --------------------------------------------------------------------------- + // let globalBarEl = null; let globalBarBrandEl = null; @@ -6166,6 +7462,8 @@ void main() { let pageChatExpanded = false; let steerLocked = false; let steerRequestId = null; + let steerPendingMessage = ''; + let steerInputWasFocused = false; let pageChatDotsEl = null; let steerAwaitTimer = null; let voiceRecognition = null; @@ -6179,7 +7477,7 @@ void main() { const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; - const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect'; + const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect'; const GLOBAL_BAR_SECTION_GAP = 8; const GLOBAL_BAR_INNER_GAP = 2; const GLOBAL_BAR_INNER_PAD_LEFT = 2; @@ -6323,7 +7621,7 @@ void main() { const attempt = () => { steerFocusRecoverTimer = null; if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (pageHasHostTextSelection()) { steerFocusRecoverTimer = setTimeout(attempt, 120); return; @@ -6344,7 +7642,7 @@ void main() { steerFocusSuspended = true; steerFocusPauseUntil = performance.now() + STEER_PAGE_FOCUS_PAUSE_MS; pagePointerGesture = { x: e.clientX, y: e.clientY, dragged: false }; - if (pageChatInput && document.activeElement === pageChatInput) { + if (pageChatInput && activeElementDeep() === pageChatInput) { pageChatInput.blur(); } } @@ -6404,7 +7702,7 @@ void main() { pickActive, pageChatReady: !!pageChatInput, pageChatExpanded, - active: steerFocusTargetLabel(document.activeElement), + active: steerFocusTargetLabel(activeElementDeep()), shouldSteer: shouldFocusSteerChat(), ...(extra || {}), }); @@ -6423,26 +7721,26 @@ void main() { function focusConfigureInput(reason) { steerFocusLog('focusConfigureInput', { reason }); const inputId = configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input'; - const input = document.getElementById(inputId); + const input = uiGetById(inputId); if (!input) { steerFocusLog('focusConfigureInput missing', { reason }); return; } setTimeout(() => { - const before = document.activeElement; + const before = activeElementDeep(); input.focus(); steerFocusLog('focusConfigureInput result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== input, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== input, }); }, 60); } function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; - const focused = document.activeElement === pageChatInput; + const focused = activeElementDeep() === pageChatInput; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; const P = pageChatPalette(); pageChatEl.style.borderColor = steerLocked @@ -6476,15 +7774,15 @@ void main() { } syncPageChatVisual(); pageChatInput.style.pointerEvents = 'auto'; - const before = document.activeElement; + const before = activeElementDeep(); try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== pageChatInput, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== pageChatInput, }); } @@ -6515,6 +7813,37 @@ void main() { return wrap; } + function keepSteerPointerInside(e, opts = {}) { + e.stopPropagation(); + if (opts.preventDefault !== false) e.preventDefault(); + } + + function preparePageChatInputForTyping() { + if (!pageChatEl || !pageChatInput) return false; + pageChatExpanded = true; + pageChatEl.dataset.expanded = 'true'; + pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; + pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return true; + } + + function focusPageChatInput(reason) { + if (!preparePageChatInputForTyping() || steerLocked) return false; + try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } + const focused = activeElementDeep() === pageChatInput; + if (focused) steerInputWasFocused = true; + syncPageChatFocusRing(); + return focused; + } + function clearSteerAwaitTimer() { if (steerAwaitTimer) { clearTimeout(steerAwaitTimer); @@ -6528,6 +7857,7 @@ void main() { if (!steerLocked || steerRequestId !== id) return; unlockSteerChat({ error: 'Steer timed out waiting for the agent. Check that live-poll is running and replies with steer_done.', + restoreMessage: steerPendingMessage, }); }, STEER_AWAIT_TIMEOUT_MS); } @@ -6538,19 +7868,12 @@ void main() { steerLocked = true; pageChatEl.dataset.processing = 'true'; pageChatInput.disabled = true; - pageChatInput.value = ''; - pageChatInput.blur(); + preparePageChatInputForTyping(); if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = true; pageChatVoiceBtn.style.display = 'none'; } - pageChatExpanded = false; - pageChatEl.dataset.expanded = 'false'; - pageChatEl.style.width = PAGE_CHAT_PROCESSING_W; pageChatEl.style.cursor = 'default'; - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; pageChatInput.style.pointerEvents = 'none'; if (pageChatHint) { pageChatHint.style.display = 'none'; @@ -6568,17 +7891,26 @@ void main() { function unlockSteerChat(opts) { clearSteerAwaitTimer(); + const restoreMessage = typeof opts?.restoreMessage === 'string' ? opts.restoreMessage : ''; + const keepExpanded = Boolean(opts?.error && restoreMessage); steerLocked = false; + const completedId = steerRequestId; steerRequestId = null; if (!pageChatEl) return; pageChatEl.dataset.processing = 'false'; pageChatEl.removeAttribute('aria-busy'); pageChatEl.setAttribute('aria-label', 'Steer the page'); - pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W; + pageChatExpanded = keepExpanded; + pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false'; + pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W; pageChatEl.style.cursor = 'pointer'; if (pageChatInput) { pageChatInput.disabled = false; - pageChatInput.value = ''; + pageChatInput.value = keepExpanded ? restoreMessage : ''; + pageChatInput.style.width = keepExpanded ? '' : '0'; + pageChatInput.style.padding = keepExpanded ? '0 6px' : '0'; + pageChatInput.style.opacity = keepExpanded ? '1' : '0'; + pageChatInput.style.pointerEvents = 'auto'; } if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = false; @@ -6586,18 +7918,28 @@ void main() { } if (pageChatHint) { pageChatHint.textContent = 'Steer'; - pageChatHint.style.display = ''; - pageChatHint.style.visibility = ''; + pageChatHint.style.display = keepExpanded ? 'none' : ''; + pageChatHint.style.visibility = keepExpanded ? 'hidden' : ''; + pageChatHint.style.opacity = keepExpanded ? '0' : '1'; } if (pageChatDotsEl?.parentNode) { pageChatDotsEl.remove(); pageChatDotsEl = null; } + steerPendingMessage = keepExpanded ? restoreMessage : ''; + steerInputWasFocused = false; syncPageChatChrome(); syncPageChatFocusRing(); if (opts?.error) showToast(String(opts.error), 5000); else if (opts?.message) showToast(String(opts.message), 4000); - syncPageChatFocus('steer-unlock'); + if (completedId) { + sendSteerCheckpoint(completedId, opts?.error ? 'steer_error' : 'steer_done', { + message: opts?.message || opts?.error || '', + file: opts?.file || '', + }); + } + if (keepExpanded) focusPageChatInput('steer-error-restore'); + else syncPageChatFocus('steer-unlock'); } function steerSpeechRecognitionCtor() { @@ -6651,7 +7993,7 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = document.getElementById(PREFIX + '-configure-voice'); + const voiceBtn = uiGetById(PREFIX + '-configure-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -6784,7 +8126,7 @@ void main() { } function configureVoiceContext() { - const input = document.getElementById( + const input = uiGetById( configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input', ); return { @@ -6819,26 +8161,37 @@ void main() { if (!text || steerLocked) return; const id = id8(); steerRequestId = id; + steerPendingMessage = text; + if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); lockSteerChat(); scheduleSteerAwaitTimeout(id); + sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); sendEvent({ type: 'steer', id, message: text, pageUrl: location.href, }).then((res) => { - if (!res) unlockSteerChat({ error: 'Could not reach live server' }); + if (!res) { + sendSteerCheckpoint(id, 'steer_send_failed', { message: text }); + unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text }); + } }); } function maybeCompleteSteer(msg) { if (!steerRequestId || msg.id !== steerRequestId) return false; if (msg.type === 'steer_done') { - unlockSteerChat({ message: msg.message }); + unlockSteerChat({ message: msg.message, file: msg.file }); + if (msg.file && /\.svelte(?:$|\?)/.test(String(msg.file))) { + setTimeout(() => { + if (!steerLocked) showToast('Steer applied. Reload if the page has not refreshed yet.', 5000); + }, 4500); + } return true; } if (msg.type === 'error') { - unlockSteerChat({ error: msg.message || 'Steer failed' }); + unlockSteerChat({ error: msg.message || 'Steer failed', restoreMessage: steerPendingMessage }); return true; } return false; @@ -6847,21 +8200,10 @@ void main() { function expandPageChat(opts) { const focus = !opts || opts.focus !== false; if (!pageChatEl || !pageChatInput || steerLocked) return; - pageChatExpanded = true; - pageChatEl.dataset.expanded = 'true'; - pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; - pageChatEl.style.cursor = 'text'; - if (pageChatHint) { - pageChatHint.style.display = 'none'; - pageChatHint.style.opacity = '0'; - } - pageChatInput.style.width = ''; - pageChatInput.style.padding = '0 6px'; - pageChatInput.style.opacity = '1'; - pageChatInput.style.pointerEvents = 'auto'; + preparePageChatInputForTyping(); syncPageChatChrome(); syncPageChatFocusRing(); - if (focus) pageChatInput.focus(); + if (focus) focusPageChatInput('expand-page-chat'); } function collapsePageChat(opts) { @@ -6878,7 +8220,7 @@ void main() { } else { pageChatInput.style.pointerEvents = 'auto'; } - if (pageChatHint && document.activeElement !== pageChatInput) { + if (pageChatHint && activeElementDeep() !== pageChatInput) { pageChatHint.style.display = ''; pageChatHint.style.opacity = '1'; } @@ -6952,7 +8294,7 @@ void main() { pageChatEl.appendChild(pageChatInput); pageChatEl.appendChild(pageChatVoiceBtn); - if (!document.getElementById(PREFIX + '-page-chat-style')) { + if (!uiGetById(PREFIX + '-page-chat-style')) { const s = document.createElement('style'); s.id = PREFIX + '-page-chat-style'; s.textContent = @@ -6966,23 +8308,34 @@ void main() { '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } - pageChatEl.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { + keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat(); + expandPageChat({ focus: false }); + focusPageChatInput('page-chat-click'); }); - pageChatVoiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); + pageChatVoiceBtn.addEventListener('mousedown', keepSteerPointerInside); pageChatVoiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); + keepSteerPointerInside(e); if (steerLocked) return; toggleSteerVoice(); }); + pageChatInput.addEventListener('pointerdown', keepSteerPointerInside); + pageChatInput.addEventListener('mousedown', keepSteerPointerInside); + pageChatInput.addEventListener('click', (e) => { + keepSteerPointerInside(e); + if (!steerLocked) focusPageChatInput('page-chat-input-click'); + }); + pageChatInput.addEventListener('input', () => { syncPageChatVisual(); }); @@ -6995,7 +8348,7 @@ void main() { syncPageChatFocusRing(); setTimeout(() => { if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (!pageChatInput.value.trim()) collapsePageChat(); scheduleSteerFocusRecover('steer-blur-recover'); }, 120); @@ -7039,7 +8392,7 @@ void main() { globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false'; globalBarBrandEl.setAttribute('aria-label', connected ? 'Impeccable live mode' - : 'Impeccable live mode: agent not polling'); + : 'Impeccable live mode - agent not polling'); globalBarBrandEl.removeAttribute('title'); globalBarBrandEl.style.cursor = connected ? 'default' : 'help'; const mark = globalBarBrandEl.querySelector('[data-brand-mark]'); @@ -7077,7 +8430,7 @@ void main() { }); agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip'; agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP; - document.body.appendChild(agentPollTooltipEl); + uiAppend(agentPollTooltipEl); return agentPollTooltipEl; } @@ -7131,7 +8484,7 @@ void main() { // Custom focus-visible for bar buttons. Browser default is a heavy // blue ring that looks jarring on the dark capsule. Replace with a // soft accent-tinted inner ring that respects the bar's palette. - if (!document.getElementById(PREFIX + '-bar-focus-style')) { + if (!uiGetById(PREFIX + '-bar-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-bar-focus-style'; s.textContent = @@ -7143,7 +8496,7 @@ void main() { '@keyframes impeccable-agent-dot { 0%, 100% { opacity: 0.45; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1); } }' + '#' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: impeccable-agent-dot 1.4s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: none; opacity: 0.9; } }'; - document.head.appendChild(s); + uiAppendStyle(s); } globalBarEl = el('div', { @@ -7176,7 +8529,7 @@ void main() { brand.id = PREFIX + '-global-bar-brand'; brand.dataset.agentConnected = 'false'; brand.setAttribute('role', 'img'); - brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling'); + brand.setAttribute('aria-label', 'Impeccable live mode - agent not polling'); const brandMark = el('span', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -7211,7 +8564,7 @@ void main() { inner.id = PREFIX + '-global-bar-inner'; globalBarEl.appendChild(inner); - // --- button factory: icon-only at rest, label slides in on hover/active --- + // Button factory: icon-only at rest, label slides in on hover/active. function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { const b = el('button', { position: 'relative', @@ -7506,6 +8859,7 @@ void main() { color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', }); + exitBtn.id = PREFIX + '-exit'; exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = 'oklch(58% 0.15 35)'; exitBtn.style.background = P.exitHover; }); @@ -7530,8 +8884,8 @@ void main() { try { window.focus(); } catch { /* in-app preview may block */ } }, true); - document.body.appendChild(pendingDockEl); - document.body.appendChild(globalBarEl); + uiAppend(pendingDockEl); + uiAppend(globalBarEl); defangOutsideHandlers(pendingDockEl); defangOutsideHandlers(globalBarEl); @@ -7553,11 +8907,11 @@ void main() { } function updateGlobalBarState() { - const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); - const detectBadge = document.getElementById(PREFIX + '-detect-badge'); - const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); - const insertToggle = document.getElementById(PREFIX + '-insert-toggle'); - const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const detectToggle = uiGetById(PREFIX + '-detect-toggle'); + const detectBadge = uiGetById(PREFIX + '-detect-badge'); + const pickToggle = uiGetById(PREFIX + '-pick-toggle'); + const insertToggle = uiGetById(PREFIX + '-insert-toggle'); + const designToggle = uiGetById(PREFIX + '-design-toggle'); const theme = globalBarEl?.dataset.theme || 'light'; const P = barPaletteForTheme(theme); @@ -7751,8 +9105,9 @@ void main() { pendingApplyInFlight = false; } if (globalBarEl) { - globalBarEl.style.transform = 'translateY(100%)'; - setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300); + globalBarEl.style.transition = 'none'; + globalBarEl.remove(); + globalBarEl = null; } pageChatEl = null; pageChatInput = null; @@ -7765,6 +9120,7 @@ void main() { if (barEl) { barEl.remove(); barEl = null; } if (pickerEl) { pickerEl.remove(); pickerEl = null; } if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; } + if (editBadgeProxyRoot) { editBadgeProxyRoot.remove(); editBadgeProxyRoot = null; editBadgeProxyByTarget = new Map(); } if (evtSource) { evtSource.close(); evtSource = null; } document.removeEventListener('mousemove', handleMouseMove, true); document.removeEventListener('click', handleClick, true); @@ -7777,9 +9133,9 @@ void main() { console.log('[impeccable] Live mode exited.'); } - // --------------------------------------------------------------------------- + // // Design System Panel - visualizes the project's .impeccable/design.json sidecar - // --------------------------------------------------------------------------- + // const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; const DESIGN_PANEL_WIDTH = 440; @@ -7847,7 +9203,7 @@ void main() { root.className = 'root'; designShadow.appendChild(root); - document.body.appendChild(designHost); + uiAppend(designHost); // The host is pointer-events: none; the panel inside the shadow DOM // manages its own auto/none. Events bubble through the shadow boundary, // so attaching here silences host-page outside-interaction handlers @@ -7889,7 +9245,7 @@ void main() { .root * { box-sizing: border-box; } button { font: inherit; color: inherit; } - /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + /* Panel shell: chrome matches the bar; body canvas stays neutral */ .panel { position: fixed; top: 12px; bottom: 72px; right: 12px; width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); @@ -7955,7 +9311,7 @@ void main() { .panel-body::-webkit-scrollbar { width: 8px; } .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } - /* --- States --- */ + /* States */ .empty, .loading, .error { margin: 16px 4px; padding: 28px 20px; text-align: center; @@ -7966,7 +9322,7 @@ void main() { .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; } .error { color: oklch(45% 0.15 25); } - /* --- Stale hint --- */ + /* Stale hint */ .stale { display: flex; align-items: center; gap: 8px; margin: 8px 4px 12px; @@ -7979,7 +9335,7 @@ void main() { .stale-text { flex: 1; min-width: 0; } .stale-text strong { color: ${DP.ink}; font-weight: 600; } - /* --- Parsed-md fallback banner --- */ + /* Parsed-md fallback banner */ .parsed-md-cta { margin: 8px 4px 14px; padding: 14px 16px; @@ -7991,7 +9347,7 @@ void main() { .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; } .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; } - /* --- Tile primitives --- */ + /* Tile primitives */ .tile { position: relative; background: ${DP.tile}; @@ -8010,7 +9366,7 @@ void main() { } .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; } - /* --- Color tile --- */ + /* Color tile */ .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; } .c-tile:hover { transform: translateY(-1px); } .c-hero { @@ -8025,7 +9381,7 @@ void main() { .c-ramp > span { flex: 1; } .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Type tile --- */ + /* Type tile */ .t-tile { } .t-specimen { margin: 4px 0 6px; @@ -8035,7 +9391,7 @@ void main() { .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; } .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Shadow tile --- */ + /* Shadow tile */ .s-tile { } .s-surface { height: 60px; margin: 8px 2px 10px; @@ -8045,14 +9401,14 @@ void main() { .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; } .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; } - /* --- Radii strip --- */ + /* Radii strip */ .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; } .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; } .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); } .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; } .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; } - /* --- Component tile (hosts live primitives) --- */ + /* Component tile (hosts live primitives) */ .cmp-tile { } .cmp-stage { margin: 12px -4px 0; @@ -8066,7 +9422,7 @@ void main() { .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; } .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; } - /* --- Collapsible --- */ + /* Collapsible */ .coll { margin: 0 4px 8px; background: ${DP.tile}; @@ -8131,7 +9487,7 @@ void main() { .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; } .coll .overview-body li { margin-bottom: 3px; } - /* --- raw tab markdown (unchanged layout, neutralized palette) --- */ + /* raw tab markdown (unchanged layout, neutralized palette) */ .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; } .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; } .md h1 { font-size: 18px; } @@ -8303,7 +9659,7 @@ void main() { return box; } - // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 --- + // Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 function renderDesignVisual(body, parsed, sidecar) { const frontmatter = parsed?.frontmatter || {}; @@ -8667,7 +10023,7 @@ void main() { return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components'); } - // --- Collapsibles --------------------------------------------------------- + // Collapsibles. function buildCollapsible(key, label, count) { const wrap = document.createElement('div'); @@ -8775,7 +10131,7 @@ void main() { return s.replace(/\s+#.*$/, '').trim(); } - // --- Raw tab: minimal markdown renderer (subset) -------------------------- + // Raw tab: minimal markdown renderer (subset) function renderRawTab(body, md) { const wrap = document.createElement('div'); @@ -8908,9 +10264,9 @@ void main() { } catch { /* ignore */ } } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // function init() { try { history.scrollRestoration = 'manual'; } catch {} diff --git a/.claude/skills/impeccable/scripts/live-completion.mjs b/.claude/skills/impeccable/scripts/live-completion.mjs index 86b637fff..986773066 100644 --- a/.claude/skills/impeccable/scripts/live-completion.mjs +++ b/.claude/skills/impeccable/scripts/live-completion.mjs @@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.mode === 'error') return 'error'; + if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error'; return 'agent_done'; } diff --git a/.claude/skills/impeccable/scripts/live-inject.mjs b/.claude/skills/impeccable/scripts/live-inject.mjs index b9d3df41d..3a1f36e46 100644 --- a/.claude/skills/impeccable/scripts/live-inject.mjs +++ b/.claude/skills/impeccable/scripts/live-inject.mjs @@ -17,11 +17,38 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './impeccable-paths.mjs'; +import { + applySvelteKitLiveAdapter, + detectSvelteKitProject, + removeSvelteKitLiveAdapter, +} from './live-sveltekit-adapter.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start'; +const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end'; + +export const LIVE_IGNORE_PATTERNS = Object.freeze([ + '.impeccable/hook.cache.json', + '.impeccable/live/server.json', + '.impeccable/live/sessions/', + '.impeccable/live/previews/', + '.impeccable/live/annotations/', + '.impeccable/live/cache/', + '.impeccable/live/manual-edit-apply-transaction.json', + '.impeccable/live/manual-edit-events.jsonl', + '.impeccable/live/manual-edit-evidence/', + '.impeccable/live/pending-manual-edits.json', + '.impeccable/live/deferred-svelte-component-accepts.json', + '.impeccable-live.json', + '.impeccable-live/', + 'node_modules/.impeccable-live/', + 'src/lib/impeccable/ImpeccableLiveRoot.svelte', + 'src/lib/impeccable/__runtime.js', + 'src/lib/impeccable/[0-9a-f]*/', +]); /** * Hard-excluded directory patterns. These are NEVER user-facing pages and @@ -83,8 +110,14 @@ Output (JSON): validateConfig(config); const resolvedFiles = resolveFiles(process.cwd(), config); + const svelteKit = detectSvelteKitProject(process.cwd(), config); if (args.includes('--remove')) { + if (svelteKit) { + const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config }); + console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; @@ -110,6 +143,13 @@ Output (JSON): console.error(JSON.stringify({ ok: false, error: 'missing_port' })); process.exit(1); } + const gitIgnore = ensureLiveGitIgnores(process.cwd()); + + if (svelteKit) { + const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config }); + console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); @@ -129,10 +169,68 @@ Output (JSON): }; }); const anyInserted = results.some((r) => r.inserted); - console.log(JSON.stringify({ ok: anyInserted, port, results })); + console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results })); if (!anyInserted) process.exit(1); } +export function ensureLiveGitIgnores(cwd = process.cwd()) { + const target = resolveIgnoreTarget(cwd); + const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : ''; + const block = [ + IGNORE_MARKER_OPEN, + ...LIVE_IGNORE_PATTERNS, + IGNORE_MARKER_CLOSE, + ].join('\n'); + const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`); + + let updated; + if (markerRe.test(existing)) { + updated = existing.replace(markerRe, block); + } else { + const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n'; + updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`; + } + + if (updated !== existing) { + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + fs.writeFileSync(target.path, updated, 'utf-8'); + } + + return { + file: path.relative(cwd, target.path).split(path.sep).join('/'), + mode: target.mode, + changed: updated !== existing, + patterns: [...LIVE_IGNORE_PATTERNS], + }; +} + +function resolveIgnoreTarget(cwd) { + const gitExcludePath = resolveGitInfoExcludePath(cwd); + if (gitExcludePath) { + return { path: gitExcludePath, mode: 'git-info-exclude' }; + } + return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' }; +} + +function resolveGitInfoExcludePath(cwd) { + const dotGit = path.join(cwd, '.git'); + if (!fs.existsSync(dotGit)) return null; + + const stat = fs.statSync(dotGit); + if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude'); + if (!stat.isFile()) return null; + + const body = fs.readFileSync(dotGit, 'utf-8').trim(); + const match = body.match(/^gitdir:\s*(.+)$/i); + if (!match) return null; + const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]); + return path.join(gitDir, 'info', 'exclude'); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Expand config.files (which may contain glob patterns) into a literal list * of existing file paths relative to rootDir. Literal entries pass through; diff --git a/.claude/skills/impeccable/scripts/live-insert.mjs b/.claude/skills/impeccable/scripts/live-insert.mjs index 09d4d55be..0658e9914 100644 --- a/.claude/skills/impeccable/scripts/live-insert.mjs +++ b/.claude/skills/impeccable/scripts/live-insert.mjs @@ -21,6 +21,11 @@ import { buildCssAuthoring, buildCssSelectorPrefixExamples, } from './live-wrap.mjs'; +import { + buildSvelteComponentCssAuthoring, + scaffoldSvelteComponentInsertSession, + shouldUseSvelteComponentInjection, +} from './live-svelte-component.mjs'; const INSERT_POSITIONS = new Set(['before', 'after']); @@ -192,6 +197,41 @@ Output (JSON): const styleMode = detectStyleMode(targetFile); const isJsx = commentSyntax.open === '{/*'; const spliceIndex = computeInsertLine(startLine, endLine, position); + const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); + + if (shouldUseSvelteComponentInjection(targetFile)) { + const session = scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile: relTargetFile, + insertLine: spliceIndex + 1, + position, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + anchorLines: lines.slice(startLine, endLine + 1), + cwd: process.cwd(), + }); + console.log(JSON.stringify({ + mode: 'insert', + position, + file: session.manifestFile, + sourceFile: relTargetFile, + previewMode: 'svelte-component', + componentDir: session.componentDir, + propContract: session.propContract, + insertLine: 1, + sourceInsertLine: spliceIndex + 1, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + commentSyntax, + styleMode: 'svelte-component', + styleTag: null, + cssSelectorPrefixExamples: [], + cssAuthoring: buildSvelteComponentCssAuthoring(count), + })); + return; + } + const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? ''; @@ -216,7 +256,7 @@ Output (JSON): console.log(JSON.stringify({ mode: 'insert', position, - file: path.relative(process.cwd(), targetFile), + file: relTargetFile, insertLine: insertLine + 1, commentSyntax, styleMode: styleMode.mode, diff --git a/.claude/skills/impeccable/scripts/live-poll.mjs b/.claude/skills/impeccable/scripts/live-poll.mjs index fad836612..1e1259fbc 100644 --- a/.claude/skills/impeccable/scripts/live-poll.mjs +++ b/.claude/skills/impeccable/scripts/live-poll.mjs @@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs'; // that ceiling and loop in `pollOnce` to synthesize a long poll without // depending on the standalone undici package. export const PER_REQUEST_TIMEOUT_MS = 270_000; +export const DEFAULT_EVENT_LEASE_MS = 600_000; const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); @@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) { ? totalDeadline - Date.now() : PER_REQUEST_TIMEOUT_MS; const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); - const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); + const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`); if (res.status === 401) { const err = new Error('Authentication failed. The server token may have changed.'); @@ -317,7 +318,7 @@ Modes: Options: --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) - --file PATH Attach a source file path to the reply (generate flow) + --file PATH Attach a source file path to the reply (generate/steer flow) --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --help Show this help message diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs index 16c8285b9..cd1091b88 100644 --- a/.claude/skills/impeccable/scripts/live-server.mjs +++ b/.claude/skills/impeccable/scripts/live-server.mjs @@ -42,6 +42,10 @@ import { } from './live-manual-edits-buffer.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs'; +import { + applyDeferredSvelteComponentAccepts, + removeAllSvelteComponentSessions, +} from './live-svelte-component.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated @@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; +const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2; const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); function tombstoneTimedOutApplyId(eventId, details = {}) { @@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) { return entry.event; } entry.leaseUntil = Date.now() + leaseMs; + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return entry.event; } @@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) { const acknowledged = state.pendingEvents[idx].event; state.pendingEvents.splice(idx, 1); scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return acknowledged; } +function findPendingEventById(id) { + if (!id) return null; + const entry = state.pendingEvents.find((item) => item.event?.id === id); + return entry?.event || null; +} + function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; return `live-poll.mjs --reply ${id} done --data ''`; @@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) { return summary; } +function summarizeActiveSessionForClient(snapshot = {}) { + return { + id: snapshot.id, + phase: snapshot.phase, + pageUrl: snapshot.pageUrl ?? null, + sourceFile: snapshot.sourceFile ?? null, + previewFile: snapshot.previewFile ?? null, + previewMode: snapshot.previewMode ?? null, + expectedVariants: snapshot.expectedVariants ?? 0, + arrivedVariants: snapshot.arrivedVariants ?? 0, + visibleVariant: snapshot.visibleVariant ?? null, + checkpointRevision: snapshot.checkpointRevision ?? 0, + paramValues: snapshot.paramValues || {}, + }; +} + +function activeSessionSummaries() { + if (!state.sessionStore) return []; + return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot)); +} + +function cancelQueuedAnonymousExitEvents() { + let removed = 0; + for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) { + const event = state.pendingEvents[i]?.event; + if (event?.type !== 'exit' || event.id) continue; + state.pendingEvents.splice(i, 1); + removed += 1; + } + if (removed > 0) { + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); + } + return removed; +} + function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { const canceledById = new Map(); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); @@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() { clearTimeout(state.leaseTimer); state.leaseTimer = null; } - if (state.pendingPolls.length === 0) return; const now = Date.now(); const nextLeaseUntil = state.pendingEvents .map((entry) => entry.leaseUntil || 0) @@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() { state.leaseTimer = setTimeout(() => { state.leaseTimer = null; flushPendingPolls(); - }, Math.max(0, nextLeaseUntil - now)); + broadcastAgentPollingIfChanged(); + }, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS)); } function flushPendingPolls() { @@ -1032,7 +1082,9 @@ function flushPendingPolls() { } function agentPollingConnected() { - return state.pendingPolls.length > 0; + const now = Date.now(); + return state.pendingPolls.length > 0 + || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now); } function broadcastAgentPollingIfChanged() { @@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/status') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } - const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; + const sessions = activeSessionSummaries(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', @@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/events' && req.method === 'GET') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + clearTimeout(state.exitTimer); + state.exitTimer = null; + cancelQueuedAnonymousExitEvents(); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { type: 'connected', hasProjectContext: hasProjectContext(), agentPolling: agentPollingConnected(), + activeSessions: activeSessionSummaries(), }) + '\n\n'); state.sseClients.add(res); - clearTimeout(state.exitTimer); // Keepalive: SSE comment every 30s prevents silent connection drops. const heartbeat = setInterval(() => { @@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } } + if (msg.type === 'exit') { + cleanupSvelteComponentSessionsBeforeExit(); + } if (msg.type !== 'checkpoint') { enqueueEvent(msg); } @@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) { }); } +function sessionFileMetadataFromPollReply(file) { + if (!file || typeof file !== 'string') return { file }; + const normalized = file.split(path.sep).join('/'); + const base = { file: normalized }; + if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base; + if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base; + + let full; + try { + full = path.resolve(process.cwd(), normalized); + const rel = path.relative(process.cwd(), full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base; + } catch { + return base; + } + + try { + const manifest = JSON.parse(fs.readFileSync(full, 'utf-8')); + if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base; + return { + file: String(manifest.sourceFile).split(path.sep).join('/'), + sourceFile: String(manifest.sourceFile).split(path.sep).join('/'), + previewFile: normalized, + previewMode: 'svelte-component', + }; + } catch { + return base; + } +} + function handlePollPost(req, res) { let body = ''; req.on('data', (c) => { body += c; }); @@ -1965,6 +2053,16 @@ function handlePollPost(req, res) { res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); return; } + const pendingEventBeforeAck = findPendingEventById(msg.id); + if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done' + && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'steer_done_requires_file_or_message', + hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.', + })); + return; + } const acknowledgedEvent = acknowledgePendingEvent(msg.id); let skipJournalReply = false; let existingSession = null; @@ -1987,6 +2085,7 @@ function handlePollPost(req, res) { })); return; } + const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); if (state.sessionStore && msg.id && !skipJournalReply) { try { const eventType = msg.type === 'steer_done' @@ -2001,7 +2100,10 @@ function handlePollPost(req, res) { state.sessionStore.appendEvent({ type: eventType, id: msg.id, - file: msg.file, + file: replyFileMeta.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, message: msg.message, sourceEventType: acknowledgedEvent?.type, carbonize: msg.data?.carbonize === true, @@ -2010,7 +2112,16 @@ function handlePollPost(req, res) { } flushPendingPolls(); // Forward the reply to the browser via SSE - broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + broadcast({ + type: msg.type || 'done', + id: msg.id, + message: msg.message, + file: msg.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, + data: msg.data, + }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); @@ -2023,6 +2134,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { + cleanupSvelteComponentSessionsBeforeExit(); removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; @@ -2037,6 +2149,25 @@ function shutdown() { process.exit(0); } +function cleanupSvelteComponentSessionsBeforeExit() { + try { + removeAllSvelteComponentSessions(process.cwd()); + } catch (err) { + console.warn('[impeccable] Svelte component session cleanup failed:', err.message); + } +} + +function applyLegacyDeferredAcceptsOnStartup() { + try { + const result = applyDeferredSvelteComponentAccepts(process.cwd()); + if (result.applied > 0 || result.failed > 0) { + console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result)); + } + } catch (err) { + console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({ cwd: process.cwd(), reason: 'manual_edit_server_start_recovered_abandoned_transaction', }); +applyLegacyDeferredAcceptsOnStartup(); restorePendingEventsFromStore(); pruneStaleManualApplyEvidence(process.cwd()); const portArg = args.find(a => a.startsWith('--port=')); diff --git a/.claude/skills/impeccable/scripts/live-session-store.mjs b/.claude/skills/impeccable/scripts/live-session-store.mjs index 7562e3d5b..5ec4d34d4 100644 --- a/.claude/skills/impeccable/scripts/live-session-store.mjs +++ b/.claude/skills/impeccable/scripts/live-session-store.mjs @@ -106,6 +106,8 @@ function baseSnapshot(id) { phase: 'new', pageUrl: null, sourceFile: null, + previewFile: null, + previewMode: null, expectedVariants: 0, arrivedVariants: 0, visibleVariant: null, @@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; - next.sourceFile = event.file ?? next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0); next.pendingEventSeq = null; next.pendingEvent = null; if (event.carbonize === true) { @@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { } break; case 'checkpoint': + if (COMPLETED_PHASES.has(next.phase)) { + next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null }); + break; + } if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { next.phase = event.phase ?? next.phase; next.checkpointRevision = event.revision ?? next.checkpointRevision; next.activeOwner = event.owner ?? next.activeOwner; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.visibleVariant = event.visibleVariant ?? next.visibleVariant; + next.sourceFile = event.sourceFile ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; if (event.paramValues) next.paramValues = { ...event.paramValues }; } else { next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); @@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'steer_done': next.phase = 'steer_done'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.message = event.message ?? next.message; next.pendingEventSeq = null; next.pendingEvent = null; break; @@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'complete': next.phase = 'completed'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; next.pendingEventSeq = null; next.pendingEvent = null; break; diff --git a/.claude/skills/impeccable/scripts/live-svelte-component.mjs b/.claude/skills/impeccable/scripts/live-svelte-component.mjs new file mode 100644 index 000000000..dc35dc0ce --- /dev/null +++ b/.claude/skills/impeccable/scripts/live-svelte-component.mjs @@ -0,0 +1,826 @@ +/** + * Svelte live-mode component injection helpers. + * + * Variants are real .svelte components under node_modules/.impeccable-live//. + * The browser mounts them via Svelte 5 mount(); accept inlines the chosen + * variant back into the route source with props mapped to original bindings. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; + +export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live'; +export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`; +export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json'; + +const MUSTACHE_RE = /\{([^{}]+)\}/g; + +export function shouldUseSvelteComponentInjection(filePath) { + if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false; + return path.extname(filePath).toLowerCase() === '.svelte'; +} + +export function componentSessionDir(id, cwd = process.cwd()) { + return path.join(cwd, SVELTE_COMPONENT_ROOT, id); +} + +export function manifestPathForSession(id, cwd = process.cwd()) { + return path.join(componentSessionDir(id, cwd), 'manifest.json'); +} + +export function ensureRuntimeHelper(cwd = process.cwd()) { + const file = path.join(cwd, SVELTE_RUNTIME_FILE); + if (fs.existsSync(file)) return file; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8'); + return file; +} + +/** + * Extract ordered unique mustache expressions from markup (not inside ). + */ +export function extractMustacheExpressions(text) { + const expressions = []; + const seen = new Set(); + const lines = String(text || '').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('\n` + : ''; + return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n\n`; +} + +function buildInsertVariantStub(variantNum) { + return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; +} + +export function scaffoldSvelteComponentSession({ + id, + count, + sourceFile, + sourceStartLine, + sourceEndLine, + originalLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const originalMarkup = originalLines.join('\n'); + const contract = buildPropContract(extractMustacheExpressions(originalMarkup)); + const originalWithProps = substituteExprsWithProps(originalMarkup, contract); + + const manifest = { + id, + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + sourceStartLine, + sourceEndLine, + count, + propContract: contract, + originalMarkup, + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: contract, + }; +} + +export function scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile, + insertLine, + position, + anchorStartLine, + anchorEndLine, + anchorLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const anchorMarkup = (anchorLines || []).join('\n'); + const manifest = { + id, + mode: 'insert', + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + insertLine, + position, + anchorStartLine, + anchorEndLine, + originalMarkup: anchorMarkup, + anchorMarkup, + count, + propContract: [], + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: [], + }; +} + +export function findSvelteComponentManifest(id, cwd = process.cwd()) { + const direct = manifestPathForSession(id, cwd); + if (fs.existsSync(direct)) { + return readManifest(direct); + } + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return null; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(root, entry.name, 'manifest.json'); + if (!fs.existsSync(candidate)) continue; + try { + const manifest = readManifest(candidate); + if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; + } catch { /* skip */ } + } + return null; +} + +export function readManifest(manifestPath) { + const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + return { + ...data, + manifestPath, + }; +} + +export function resolveSourceFile(sourceFile, cwd = process.cwd()) { + if (!sourceFile || path.isAbsolute(sourceFile)) { + throw new Error('Invalid svelte-component source file'); + } + const full = path.resolve(cwd, sourceFile); + const rel = path.relative(cwd, full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('Svelte-component source file escapes project root'); + } + if (!fs.existsSync(full)) { + throw new Error('Svelte-component source file not found: ' + sourceFile); + } + return full; +} + +function appendCssToSvelteStyle(lines, cssLines) { + const closeIdx = findLastStyleCloseLine(lines); + const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; + if (closeIdx === -1) { + return [...lines, '', '']; + } + return [ + ...lines.slice(0, closeIdx), + ...prepared, + ...lines.slice(closeIdx), + ]; +} + +function findLastStyleCloseLine(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + if (/<\/style\s*>/.test(lines[i])) return i; + } + return -1; +} + +function bakeParamValuesInCss(cssLines, paramValues) { + if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; + return cssLines.map((line) => { + let out = line; + for (const [key, value] of Object.entries(paramValues)) { + const varName = `--p-${key}`; + out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); + } + return out; + }); +} + +function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { + const css = String((cssLines || []).join('\n')); + if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; + + const rules = parseCssRules(css); + const output = []; + for (const rule of rules) { + appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); + } + return output.join('\n') + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ''); +} + +function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { + const prelude = rule.prelude.trim(); + const body = rule.body.trim(); + if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; + + if (/^@scope\b/i.test(prelude)) { + if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; + const inner = parseCssRules(body); + for (const innerRule of inner) { + const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); + if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; + output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); + } + return; + } + + const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); + if (!rewrittenPrelude) return; + output.push(formatCssRule(rewrittenPrelude, body)); +} + +function parseCssRules(css) { + const rules = []; + const text = String(css || ''); + let i = 0; + while (i < text.length) { + while (i < text.length && /\s/.test(text[i])) i++; + const preludeStart = i; + while (i < text.length && text[i] !== '{') i++; + if (i >= text.length) break; + const prelude = text.slice(preludeStart, i).trim(); + i++; + const bodyStart = i; + let depth = 1; + let quote = null; + let comment = false; + while (i < text.length && depth > 0) { + const ch = text[i]; + const next = text[i + 1]; + if (comment) { + if (ch === '*' && next === '/') { + comment = false; + i += 2; + continue; + } + i++; + continue; + } + if (quote) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === quote) quote = null; + i++; + continue; + } + if (ch === '/' && next === '*') { + comment = true; + i += 2; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); + if (prelude) rules.push({ prelude, body }); + } + return rules; +} + +function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { + const selectors = splitSelectorList(prelude); + const rewritten = []; + for (const selector of selectors) { + const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); + if (next) rewritten.push(next); + } + return rewritten.join(', '); +} + +function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { + let out = selector.trim(); + const hasVariant = /data-impeccable-variant/.test(out); + if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; + if (hasVariant) { + out = out.replace(variantSelectorRegex(variantNum), ''); + out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); + } + + const paramResult = rewriteParamSelectors(out, paramValues); + if (!paramResult.keep) return ''; + out = paramResult.selector; + + out = out + .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') + .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') + .replace(/\s+/g, ' ') + .trim(); + + out = out.replace(/^[>+~]\s*/, '').trim(); + if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; + return out; +} + +function rewriteParamSelectors(selector, paramValues) { + let keep = true; + const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { + if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; + const actual = paramValues[key]; + if (expected != null && String(actual) !== String(expected)) { + keep = false; + return ''; + } + if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { + keep = false; + return ''; + } + return ''; + }); + return { keep, selector: next }; +} + +function splitSelectorList(prelude) { + const selectors = []; + let start = 0; + let bracket = 0; + let paren = 0; + let quote = null; + for (let i = 0; i < prelude.length; i++) { + const ch = prelude[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '[') bracket++; + else if (ch === ']') bracket = Math.max(0, bracket - 1); + else if (ch === '(') paren++; + else if (ch === ')') paren = Math.max(0, paren - 1); + else if (ch === ',' && bracket === 0 && paren === 0) { + selectors.push(prelude.slice(start, i)); + start = i + 1; + } + } + selectors.push(prelude.slice(start)); + return selectors; +} + +function selectorHasVariant(selector, variantNum) { + return variantSelectorRegex(variantNum).test(selector); +} + +function variantSelectorRegex(variantNum) { + return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); +} + +function formatCssRule(selector, body) { + return `${selector} { ${body.trim()} }`; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { + const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); + const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); + const resultBase = { + file: manifest.sourceFile, + sourceFile: manifest.sourceFile, + previewMode: 'svelte-component', + componentDir: manifest.componentDir, + carbonize: false, + }; + if (!fs.existsSync(variantPath)) { + return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; + } + + const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); + if (manifest.mode === 'insert') { + return inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, + }); + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const contract = manifest.propContract || []; + const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); + const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract) + .split('\n') + .map((line) => line.trimEnd()); + + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const start = Number(manifest.sourceStartLine) - 1; + const end = Number(manifest.sourceEndLine) - 1; + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { + return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; + } + + const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, start), + ...indentedMarkup, + ...sourceLines.slice(end + 1), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, +}) { + if (!svelteMarkupHasVisibleContent(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; + } + if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const restoredMarkup = String(markup || '') + .split('\n') + .map((line) => line.trimEnd()); + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const insertIndex = Number(manifest.insertLine) - 1; + if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { + return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; + } + + const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; + const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, insertIndex), + ...indentedMarkup, + ...sourceLines.slice(insertIndex), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function svelteMarkupHasVisibleContent(markup) { + const text = String(markup || '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 0) return true; + return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); +} + +function mergeOriginalTopLevelAttrs(markup, originalMarkup) { + const variantOpen = matchOpeningTag(markup); + const originalOpen = matchOpeningTag(originalMarkup); + if (!variantOpen || !originalOpen) return markup; + if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; + + const variantAttrs = parseAttrSegments(variantOpen.attrs); + const originalAttrs = parseAttrSegments(originalOpen.attrs); + const additions = []; + let attrs = variantOpen.attrs; + + const originalClass = originalAttrs.get('class'); + const variantClass = variantAttrs.get('class'); + if (originalClass && variantClass) { + const merged = mergeStaticClassAttr(originalClass, variantClass); + if (merged) { + attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); + variantAttrs.set('class', { ...variantClass, raw: merged }); + } + } else if (originalClass && !variantClass) { + additions.push(originalClass.raw); + } + + for (const [name, attr] of originalAttrs) { + if (name === 'class') continue; + if (!variantAttrs.has(name)) additions.push(attr.raw); + } + + if (additions.length === 0 && attrs === variantOpen.attrs) return markup; + const nextOpen = variantOpen.prefix + + variantOpen.tag + + attrs + + additions.map((attr) => ' ' + attr.trim()).join('') + + variantOpen.close; + return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); +} + +function matchOpeningTag(markup) { + const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); + if (!match) return null; + return { + raw: match[0], + prefix: match[1], + tag: match[2], + attrs: match[3] || '', + close: match[4], + index: match.index || 0, + }; +} + +function parseAttrSegments(attrs) { + const out = new Map(); + const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; + let match; + while ((match = re.exec(attrs))) { + const raw = match[0]; + const name = match[1]; + out.set(name, { + name, + raw, + start: match.index, + end: match.index + raw.length, + }); + } + return out; +} + +function mergeStaticClassAttr(originalClass, variantClass) { + const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + if (!originalValue || !variantValue) return null; + const quote = variantValue[1]; + const classes = [ + ...variantValue[2].split(/\s+/), + ...originalValue[2].split(/\s+/), + ].filter(Boolean); + return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; +} + +export function removeSvelteComponentSession(id, cwd = process.cwd()) { + const dir = componentSessionDir(id, cwd); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* non-fatal */ } +} + +export function removeAllSvelteComponentSessions(cwd = process.cwd()) { + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('__')) continue; + try { + fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + } catch { /* non-fatal */ } + } +} + +export function deferredAcceptsPath(cwd = process.cwd()) { + const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16); + return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json'); +} + +export function readDeferredAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch { + return { accepts: [] }; + } +} + +export function writeDeferredAccept(entry, cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const data = readDeferredAccepts(cwd); + data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id); + data.accepts.push({ ...entry, createdAt: new Date().toISOString() }); + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + const data = readDeferredAccepts(cwd); + const pending = Array.isArray(data.accepts) ? data.accepts : []; + const results = []; + const remaining = []; + for (const entry of pending) { + try { + const manifest = findSvelteComponentManifest(entry.id, cwd); + if (!manifest) { + results.push({ id: entry.id, ok: false, error: 'manifest not found' }); + remaining.push(entry); + continue; + } + const result = inlineSvelteComponentAccept( + manifest, + entry.variantNum, + entry.paramValues || null, + cwd, + ); + results.push({ id: entry.id, ok: result.handled !== false, result }); + if (result.handled === false) remaining.push(entry); + } catch (err) { + results.push({ id: entry.id, ok: false, error: err.message }); + remaining.push(entry); + } + } + if (remaining.length > 0) { + fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8'); + } else { + try { fs.rmSync(file, { force: true }); } catch {} + } + return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results }; +} + +export function buildSvelteComponentCssAuthoring(count) { + const variantNumbers = Array.from({ length: count }, (_, i) => i + 1); + return { + mode: 'svelte-component', + styleTag: null, + strategy: 'component-style-block', + rulePattern: '.semantic-class { ... }', + selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'), + requirements: [ + 'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).', + 'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.', + 'Put variant CSS in the component ' : '')); + if (paramValues && Object.keys(paramValues).length > 0) { + lines.push( + bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close, + ); + } + lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + lines.push(bodyIndent + '
'); + lines.push(...bodyRestored); + lines.push(bodyIndent + '
'); + }; + + if (isJsx) { + const wrapperStyle = 'style={{ display: "contents" }}'; + lines.push(indent + '
'); + pushCarbonizeBody(indent + ' '); + lines.push(indent + '
'); + } else { + pushCarbonizeBody(indent); + } + + return lines; +} + +function reindentContent(contentLines, fromIndent, toIndent) { + return contentLines.map((line) => { + if (line.trim() === '') return ''; + if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length); + return toIndent + line.trimStart(); + }); +} + function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; @@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const needsCarbonize = !!(cssContent || hasHelperAttrs); - // Build the replacement const restored = deindentContent(variantContent, indent); - const replacement = []; - - if (cssContent) { - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); - // JSX targets need the CSS body wrapped in a template literal so that the - // `{` and `}` in CSS rules don't get parsed as JSX expressions. - replacement.push(indent + '' : '')); - if (paramValues && Object.keys(paramValues).length > 0) { - // Preserve the user's knob positions for the carbonize-cleanup agent - // to bake into the final CSS when it collapses scoped rules. - replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); - } - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); - } - - // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the - // carbonize CSS block working visually by re-wrapping the accepted content - // in a data-impeccable-variant="N" div with `display: contents` (so layout - // isn't affected). The carbonize agent strips this attribute + wrapper when - // it moves the CSS to a proper stylesheet. - // - // Style attribute syntax has to follow the host file's flavor — JSX files - // need the object form, otherwise React 19 throws "Failed to set indexed - // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. - if (cssContent) { - const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; - replacement.push(indent + '
'); - replacement.push(...restored); - replacement.push(indent + '
'); - } else { - replacement.push(...restored); - } + const replacement = buildCarbonizeReplacement({ + indent, + commentSyntax, + isJsx, + id, + variantNum, + cssContent, + paramValues, + restored, + }); const newLines = [ ...lines.slice(0, replaceRange.start), @@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; } +function readSourceShadowPreviewMeta(content, id) { + const escaped = escapeRegExp(id); + const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>'); + const match = String(content || '').match(wrapperRe); + if (!match) return null; + const tag = match[0]; + if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null; + const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file'); + const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start')); + const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end')); + if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null; + return { sourceFile, sourceStartLine, sourceEndLine }; +} + +function readHtmlAttr(tag, name) { + const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1')); + if (!match) return null; + return decodeHtmlAttr(match[2]); +} + +function decodeHtmlAttr(value) { + return String(value || '') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- @@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts }; diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index f609ac847..d7c580e2f 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -26,9 +26,9 @@ return; } - // --------------------------------------------------------------------------- + // // Design tokens - // --------------------------------------------------------------------------- + // // Brand kinpaku (gold) is pinned to the site's neo-kinpaku tokens // (see site/styles/kinpaku-tokens.css) so Accept / knobs / cycle-dots / @@ -115,19 +115,48 @@ { value: 'overdrive', label: 'Overdrive' }, ]; - // --------------------------------------------------------------------------- + const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions']; + const LIVE_UI_SURFACES = [ + { 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'] }, + { key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] }, + { key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-configure-input-wrap', PREFIX + '-input', PREFIX + '-configure-voice'] }, + { key: 'action-picker', ids: [PREFIX + '-picker'] }, + { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, + { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, + { key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] }, + { key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] }, + { key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] }, + { 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'] }, + { key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] }, + { key: 'design-system-panel', ids: [PREFIX + '-design-host'] }, + { key: 'toasts-and-errors', ids: [PREFIX + '-toast'] }, + { key: 'css-isolation-boundary', ids: [PREFIX + '-root'] }, + ]; + const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))]; + + // // State - // --------------------------------------------------------------------------- + // let state = 'IDLE'; let hoveredElement = null; let selectedElement = null; let currentSessionId = null; - let pendingAcceptedSession = null; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; + let svelteComponentSession = null; + let svelteRuntimePromise = null; + let pendingSvelteComponentRetryObserver = null; + let currentSourceFile = null; + let currentPreviewFile = null; + let currentPreviewMode = null; + let recoveryWaitingForAnchor = false; + let pendingAcceptedSession = null; let variantObserver = null; + let variantSelectionInFlight = false; + let variantSelectionPromise = null; + let recoveringEmptyCycling = false; let hasProjectContext = false; let selectedAction = 'impeccable'; let selectedCount = 3; @@ -175,14 +204,17 @@ let highlightEl = null; let tooltipEl = null; let barEl = null; + let barHideSeq = 0; let pickerEl = null; let toastEl = null; let scrollRaf = null; let editBadgeEl = null; + let editBadgeProxyRoot = null; + let editBadgeProxyByTarget = new Map(); - // --------------------------------------------------------------------------- + // // Helpers - // --------------------------------------------------------------------------- + // function own(el) { return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); @@ -204,8 +236,105 @@ return s; } + function rectIsUsableAnchor(rect) { + return !!rect && rect.width > 0.5 && rect.height > 0.5; + } + + function makeFrozenAnchor(el) { + if (!el || !el.getBoundingClientRect) return null; + const r = el.getBoundingClientRect(); + if (!rectIsUsableAnchor(r)) return null; + const rect = { + x: r.x, y: r.y, + top: r.top, left: r.left, + right: r.right, bottom: r.bottom, + width: r.width, height: r.height, + }; + return { + __impeccableFrozenAnchor: true, + tagName: el.tagName || 'DIV', + id: el.id || '', + classList: el.classList ? [...el.classList] : [], + hasAttribute: () => false, + getBoundingClientRect: () => rect, + }; + } + function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } + function cssId(id) { + if (window.CSS?.escape) return CSS.escape(id); + return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); + } + + function liveUiRoot() { + const root = window.__IMPECCABLE_LIVE_UI_ROOT__; + if (root && typeof root.appendChild === 'function') return root; + return document.body; + } + + function uiAppend(el) { + liveUiRoot().appendChild(el); + return el; + } + + function uiAppendStyle(styleEl) { + const root = liveUiRoot(); + if (root && root !== document.body) root.appendChild(styleEl); + else document.head.appendChild(styleEl); + return styleEl; + } + + function uiGetById(id) { + const root = liveUiRoot(); + if (root?.getElementById) { + const found = root.getElementById(id); + if (found) return found; + } + if (root?.querySelector) { + const found = root.querySelector('#' + cssId(id)); + if (found) return found; + } + return document.getElementById(id); + } + + function activeElementDeep() { + let active = document.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; + return active; + } + + window.__IMPECCABLE_LIVE_CHROME_CORE__ = { + version: 1, + adapter: window.__IMPECCABLE_LIVE_ADAPTER__ || 'dom', + mountContract: LIVE_CHROME_MOUNT_CONTRACT, + surfaces: LIVE_UI_SURFACES, + componentIds: LIVE_UI_COMPONENT_IDS, + root: liveUiRoot, + append: uiAppend, + appendStyle: uiAppendStyle, + getById: uiGetById, + activeElementDeep, + debugState: () => ({ + state, + currentSessionId, + expectedVariants, + arrivedVariants, + visibleVariant, + savedSession: loadSession(), + sourceFile: currentSourceFile, + previewFile: currentPreviewFile, + previewMode: currentPreviewMode, + barText: barEl?.textContent || null, + barConnected: !!barEl?.isConnected, + hasSvelteComponentSession: !!svelteComponentSession, + mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0, + pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver, + recoveryWaitingForAnchor, + evtSourceReadyState: evtSource ? evtSource.readyState : null, + }), + }; + // Modal-aware chrome: keep our floating UI clickable inside Radix / // Headless UI / vaul portals. // @@ -245,9 +374,9 @@ rootEl.addEventListener('focusin', stop); } - // --------------------------------------------------------------------------- + // // Highlight overlay - // --------------------------------------------------------------------------- + // function initHighlight() { highlightEl = document.createElement('div'); @@ -259,7 +388,7 @@ transition: HIGHLIGHT_TRANSITION, display: 'none', opacity: '0', }); - document.body.appendChild(highlightEl); + uiAppend(highlightEl); tooltipEl = document.createElement('div'); tooltipEl.id = PREFIX + '-tooltip'; @@ -273,7 +402,7 @@ letterSpacing: '0.02em', transition: TOOLTIP_TRANSITION, }); - document.body.appendChild(tooltipEl); + uiAppend(tooltipEl); } function showHighlight(el) { @@ -310,7 +439,7 @@ if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; } } - // --------------------------------------------------------------------------- + // // Annotation overlay (comment pins + kinpaku strokes) // // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned @@ -318,7 +447,7 @@ // drag) drops a comment pin; drag paints a kinpaku SVG stroke. All coords // are stored in element-local CSS px so they survive scroll / resize and // correlate directly with the captured PNG. - // --------------------------------------------------------------------------- + // const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it @@ -397,7 +526,7 @@ annotOverlayEl.addEventListener('pointermove', onAnnotMove); annotOverlayEl.addEventListener('pointerup', onAnnotUp); annotOverlayEl.addEventListener('pointercancel', onAnnotUp); - document.body.appendChild(annotOverlayEl); + uiAppend(annotOverlayEl); // Modal-host friendliness: pointer-events is already 'auto' on this // overlay; we only need to silence the host's outside-interaction // listeners. Don't override pointer-events here (the overlay toggles @@ -828,9 +957,9 @@ return wrap; } - // --------------------------------------------------------------------------- + // // Element context extraction - // --------------------------------------------------------------------------- + // function stripManualEditRuntimeState(root) { if (!root || root.nodeType !== 1) return; @@ -971,9 +1100,9 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } - // --------------------------------------------------------------------------- + // // The Bar - one floating element, three modes - // --------------------------------------------------------------------------- + // // Contextual-bar palette. Cached at init so every build*Row reads a // consistent set of colors; detectPageTheme runs once rather than on every @@ -1006,7 +1135,7 @@ padding: '6px', maxWidth: '520px', minWidth: '320px', }); - document.body.appendChild(barEl); + uiAppend(barEl); defangOutsideHandlers(barEl); } @@ -1041,6 +1170,8 @@ } function showBar(mode) { + barHideSeq += 1; + if (mode === 'cycling' && !ensureCyclingRenderable('show-bar')) return; barEl.innerHTML = ''; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); @@ -1058,11 +1189,12 @@ function hideBar() { if (!barEl) return; + const hideSeq = ++barHideSeq; stopVoice({ suppressSubmit: true }); if (configureKind === 'insert') clearInsertPicking(); barEl.style.opacity = '0'; barEl.style.transform = 'translateY(6px)'; - setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); + setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); if (state === 'EDITING') restoreInlineEditDrafts(); @@ -1071,6 +1203,7 @@ function updateBarContent(mode) { if (!barEl || barEl.style.display === 'none') return; + if (mode === 'cycling' && !ensureCyclingRenderable('update-bar')) return; barEl.innerHTML = ''; // Reset bar styling to the kinpaku picker palette barEl.style.background = BP.surface; @@ -1090,13 +1223,13 @@ syncPageChatFocus('update-bar-content'); } - // --- Configure row --- + // Configure row function syncConfigureInputChrome() { - const wrap = document.getElementById(PREFIX + '-configure-input-wrap'); - const input = document.getElementById(PREFIX + '-input'); + const wrap = uiGetById(PREFIX + '-configure-input-wrap'); + const input = uiGetById(PREFIX + '-input'); if (!wrap || !input) return; - const focused = document.activeElement === input; + const focused = activeElementDeep() === input; wrap.dataset.inputFocused = focused ? 'true' : 'false'; wrap.dataset.voiceListening = (voiceListening && voiceCtx?.mode === 'configure') ? 'true' : 'false'; wrap.style.borderColor = (voiceListening && voiceCtx?.mode === 'configure') @@ -1104,7 +1237,7 @@ : (focused ? BP.accentSoft : BP.hairline); } - // --- Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) --- + // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) function detectInsertAxisFromStyle(style) { const display = style?.display || 'block'; @@ -1377,7 +1510,7 @@ display: 'none', opacity: '0.9', }); - document.body.appendChild(insertLineEl); + uiAppend(insertLineEl); defangOutsideHandlers(insertLineEl); return insertLineEl; } @@ -1440,6 +1573,10 @@ /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { + if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor) return anchor; + } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (wrapper) { @@ -1557,6 +1694,11 @@ positionBar(); } + function showOrUpdateCyclingBar() { + if (barEl && barEl.style.display !== 'none') updateBarContent('cycling'); + else showBar('cycling'); + } + function buildPlaceholderResizeHandles() { if (!placeholderResizeLayerEl) return; placeholderResizeLayerEl.innerHTML = ''; @@ -1665,7 +1807,7 @@ } function isInsertCreateEnabled(btn) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); + btn = btn || uiGetById(PREFIX + '-insert-create'); return !!btn && btn.getAttribute('aria-disabled') !== 'true'; } @@ -1691,7 +1833,7 @@ lineHeight: '1.35', }); insertCreateTooltipEl.id = PREFIX + '-insert-create-tooltip'; - document.body.appendChild(insertCreateTooltipEl); + uiAppend(insertCreateTooltipEl); return insertCreateTooltipEl; } @@ -1723,8 +1865,8 @@ } function syncInsertCreateButton(btn, input) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); - input = input || document.getElementById(PREFIX + '-insert-input'); + btn = btn || uiGetById(PREFIX + '-insert-create'); + input = input || uiGetById(PREFIX + '-insert-input'); if (!btn || !input) return; const gate = insertCreateGateState(input); const ok = canCreateInsert(gate); @@ -1833,7 +1975,7 @@ voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!document.getElementById(PREFIX + '-configure-input-style')) { + if (!uiGetById(PREFIX + '-configure-input-style')) { const s = document.createElement('style'); s.id = PREFIX + '-configure-input-style'; s.textContent = @@ -1842,7 +1984,7 @@ '#' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: impeccable-configure-voice-pulse 1.1s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-configure-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } input.addEventListener('focus', () => syncConfigureInputChrome()); @@ -1949,6 +2091,9 @@ transition: 'border-color 0.15s ease', }); inputWrap.id = PREFIX + '-insert-input-wrap'; + inputWrap.addEventListener('pointerdown', (e) => e.stopPropagation()); + inputWrap.addEventListener('mousedown', (e) => e.stopPropagation()); + inputWrap.addEventListener('click', (e) => e.stopPropagation()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; @@ -1984,6 +2129,12 @@ voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; input.addEventListener('input', () => syncInsertCreateButton()); + input.addEventListener('pointerdown', (e) => e.stopPropagation()); + input.addEventListener('mousedown', (e) => e.stopPropagation()); + input.addEventListener('click', (e) => { + e.stopPropagation(); + try { input.focus({ preventScroll: true }); } catch { input.focus(); } + }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); @@ -2049,6 +2200,7 @@ }); create.addEventListener('mouseleave', hideInsertCreateTooltip); create.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); if (controlsLocked) { showManualApplyBusyToast(); return; } if (!isInsertCreateEnabled(create)) return; @@ -2060,7 +2212,7 @@ return row; } - // --- Generating row --- + // Generating row function buildGeneratingRow() { const row = el('div', { @@ -2086,19 +2238,24 @@ }); // Variants currently arrive atomically in a single file edit, so a // per-variant counter would lie. Say what's true. - status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + expectedVariants + ' variants...' - : 'Done'; + status.textContent = recoveryWaitingForAnchor + ? 'Variants ready. Reveal the selected element to resume.' + : (arrivedVariants < expectedVariants + ? 'Generating ' + expectedVariants + ' variants...' + : 'Done'); row.appendChild(status); return row; } - // --- Cycling row --- + // Cycling row const TUNE_ICON_SVG = ''; function buildCyclingRow() { + if (!ensureCyclingRenderable('build-cycling-row')) { + return el('div', { display: 'none' }); + } const row = el('div', { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 2px', @@ -2106,6 +2263,7 @@ // Prev const prev = navBtn('\u2190'); + prev.id = PREFIX + '-variant-prev'; prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); }); if (visibleVariant <= 1) prev.style.opacity = '0.3'; row.appendChild(prev); @@ -2118,11 +2276,13 @@ fontFamily: MONO, fontSize: '11px', fontWeight: '500', color: BP.textDim, minWidth: '24px', textAlign: 'center', }); + counter.id = PREFIX + '-variant-counter'; counter.textContent = visibleVariant + '/' + arrivedVariants; row.appendChild(counter); // Next const next = navBtn('\u2192'); + next.id = PREFIX + '-variant-next'; next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); }); if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3'; row.appendChild(next); @@ -2208,9 +2368,9 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders - // --- Saving row (waiting for agent to process accept/discard) --- + // Saving row (waiting for agent to process accept/discard) function buildSavingRow() { const row = el('div', { @@ -2235,7 +2395,7 @@ return row; } - // --- Confirmed row (green success, auto-dismisses) --- + // Confirmed row (green success, auto-dismisses) function buildConfirmedRow() { const row = el('div', { @@ -2256,7 +2416,7 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders function buildDots(clickable) { const container = el('div', { @@ -2290,10 +2450,7 @@ const idx = i; dot.addEventListener('click', (e) => { e.stopPropagation(); - visibleVariant = idx; - showVariantInDOM(currentSessionId, idx); - updateSelectedElement(); - updateBarContent('cycling'); + selectVariant(idx, 'variant_changed'); }); } container.appendChild(dot); @@ -2323,13 +2480,14 @@ function el(tag, styles) { const e = document.createElement(tag); + if (String(tag).toLowerCase() === 'button') e.type = 'button'; if (styles) Object.assign(e.style, styles); return e; } - // --------------------------------------------------------------------------- + // // Action picker popover - // --------------------------------------------------------------------------- + // function initActionPicker() { const P = barPaletteForTheme(detectPageTheme()); @@ -2384,16 +2542,20 @@ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent'; }); chip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + const prompt = uiGetById(PREFIX + '-input')?.value || ''; selectedAction = action.value; hideActionPicker(); updateBarContent('configure'); + const input = uiGetById(PREFIX + '-input'); + if (input && prompt) input.value = prompt; }); grid.appendChild(chip); }); pickerEl.appendChild(grid); - document.body.appendChild(pickerEl); + uiAppend(pickerEl); defangOutsideHandlers(pickerEl); // Cache the palette on the picker so toggleActionPicker's state refresh @@ -2433,7 +2595,33 @@ setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180); } - // --------------------------------------------------------------------------- + function ensureCyclingRenderable(reason) { + if (arrivedVariants > 0) { + if (visibleVariant < 1 || visibleVariant > arrivedVariants) visibleVariant = 1; + return true; + } + recoverEmptyCycling(reason); + return false; + } + + function recoverEmptyCycling(reason) { + if (recoveringEmptyCycling) return; + recoveringEmptyCycling = true; + try { + console.warn('[impeccable] Refusing to render empty variant cycling state:', reason); + const message = 'No variants were mounted. Please try again.'; + if (svelteComponentSession?.sessionId === currentSessionId) { + abortSvelteComponentInjection(currentSessionId, message); + return; + } + cleanup(); + showToast(message, 5000); + } finally { + recoveringEmptyCycling = false; + } + } + + // // Params panel (per-variant coarse controls) // // Variants may declare a parameter manifest via a JSON attribute on the @@ -2446,13 +2634,13 @@ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped // CSS can respond instantly without regeneration: // - // range / numeric toggle → CSS var (`--p-`) used via var(--p-foo, N) + // range / numeric toggle -> CSS custom property used by variant styles // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."] // // On variant switch, values reset to that variant's declared defaults. // On accept, current values are sent in the event payload so the agent // can bake them into the source-file write. - // --------------------------------------------------------------------------- + // let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide) let paramsPanelInner = null; // translating content (carries bg, padding, knobs) @@ -2507,7 +2695,7 @@ }); paramsPanelEl.appendChild(paramsPanelBody); - document.body.appendChild(paramsPanelEl); + uiAppend(paramsPanelEl); // Don't override pointer-events: the panel toggles between 'none' (closed, // click-through) and 'auto' (open) on its own. Just silence the host's // outside-interaction listeners while the panel is open. @@ -2516,14 +2704,40 @@ } + function getMountedSvelteComponentAnchor(session = svelteComponentSession) { + const el = session?.mountTargetEl?.firstElementChild || null; + if (!el || !document.body.contains(el)) return null; + return rectIsUsableAnchor(el.getBoundingClientRect()) ? el : null; + } + + function resolveSvelteComponentAnchor(session = svelteComponentSession) { + return getMountedSvelteComponentAnchor(session) + || session?.swapAnchor + || null; + } + function getVisibleVariantEl() { if (!currentSessionId) return null; + if (svelteComponentSession?.sessionId === currentSessionId) { + return resolveSvelteComponentAnchor() + || svelteComponentSession.wrapperEl + || null; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } function parseVariantParams(variantEl) { + // Svelte component variants can't carry a `data-impeccable-params` attribute: + // the compiler reads `{` inside attribute values as expression delimiters, so + // JSON-with-braces breaks the build. For that path the params live in a sidecar + // params.json keyed by variant number, loaded into the session at mount time. + if (svelteComponentSession?.sessionId === currentSessionId) { + const byVariant = svelteComponentSession.paramsByVariant || {}; + const params = byVariant[String(visibleVariant)] || byVariant[visibleVariant]; + return Array.isArray(params) ? params : []; + } if (!variantEl) return []; const raw = variantEl.getAttribute('data-impeccable-params'); if (!raw) return []; @@ -2685,11 +2899,11 @@ } } - // --------------------------------------------------------------------------- + // // Inline text editing - makes pure-text descendants of the picked element // directly contenteditable. Save stages copy edits in the live buffer; the // Apply copy edits dock later asks the AI to apply the staged batch. - // --------------------------------------------------------------------------- + // let inlineEditRows = []; let inlineEditDrafts = new Map(); @@ -2803,7 +3017,7 @@ function disableInlineEdit(opts = {}) { for (const row of inlineEditRows) { - if (document.activeElement === row.el) row.el.blur(); + if (activeElementDeep() === row.el) row.el.blur(); row.el.removeAttribute('contenteditable'); delete row.el.dataset.impeccableEditable; delete row.el.dataset.impeccableOriginalText; @@ -3133,7 +3347,7 @@ if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) { showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); } else { - showToast('Save failed: retry or cancel', 4000); + showToast('Save failed - retry or cancel', 4000); } } } @@ -3181,11 +3395,11 @@ } function ensureSpinKeyframes() { - if (document.getElementById(PREFIX + '-keyframes')) return; + if (uiGetById(PREFIX + '-keyframes')) return; const style = document.createElement('style'); style.id = PREFIX + '-keyframes'; style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }'; - document.head.appendChild(style); + uiAppendStyle(style); } function pendingApplyLabel(count) { @@ -3318,10 +3532,10 @@ closeTunePopover(); } if (barEl && barEl.style.display !== 'none' && state === 'CONFIGURING') { - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value : ''; updateBarContent('configure'); - const nextInput = document.getElementById(PREFIX + '-input'); + const nextInput = uiGetById(PREFIX + '-input'); if (nextInput) nextInput.value = prompt; } if (editBadgeEl && editBadgeEl.style.display !== 'none') { @@ -3455,19 +3669,19 @@ updatePendingCounter(remaining); if (result.failed && result.failed.length > 0) { console.warn('[impeccable] some copy edits failed:', result.failed); - showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed - see console', 5000); } else { const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0); if (n > 0) { showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); } else { console.warn('[impeccable] apply returned no verified edits:', result); - showToast('No edits applied, see console', 4000); + showToast('No edits applied - see console', 4000); } } } catch (err) { console.error('[impeccable] commit failed:', err); - showToast('Apply failed, see console', 4000); + showToast('Apply failed - see console', 4000); } finally { if (waitForSseCompletion) return; const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0; @@ -3497,7 +3711,7 @@ } } catch (err) { console.error('[impeccable] discard failed:', err); - showToast('Discard failed, see console', 4000); + showToast('Discard failed - see console', 4000); } } @@ -3645,7 +3859,7 @@ const failedCount = numberOrNull(msg.failedCount) || 0; const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0; if (failedCount > 0) { - showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000); + showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed - see console', 5000); } else if (appliedCount > 0) { showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500); } @@ -3799,9 +4013,164 @@ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); } - // --------------------------------------------------------------------------- + // // Edit content badge - floating button at element top-right to enter EDITING mode - // --------------------------------------------------------------------------- + // + + function usesShadowChromeRoot() { + const root = liveUiRoot(); + return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; + } + + function setImportantStyle(el, name, value) { + el.style.setProperty(name, value, 'important'); + } + + function initEditBadgeHitProxies() { + if (!usesShadowChromeRoot() || editBadgeProxyRoot) return; + editBadgeProxyRoot = document.createElement('div'); + editBadgeProxyRoot.id = PREFIX + '-edit-badge-hit-proxies'; + editBadgeProxyRoot.setAttribute('aria-hidden', 'true'); + const styles = { + all: 'initial', + position: 'fixed', + inset: '0', + width: '100vw', + height: '100vh', + zIndex: String(Z.toast + 1), + pointerEvents: 'none', + background: 'transparent', + overflow: 'visible', + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + document.body.appendChild(editBadgeProxyRoot); + } + + function styleEditBadgeProxy(proxy, target) { + const rect = target.getBoundingClientRect(); + const cursor = getComputedStyle(target).cursor || 'pointer'; + const styles = { + all: 'initial', + position: 'fixed', + left: rect.left + 'px', + top: rect.top + 'px', + width: rect.width + 'px', + height: rect.height + 'px', + margin: '0', + padding: '0', + border: '0', + borderRadius: '0', + background: 'transparent', + color: 'transparent', + opacity: '0.001', + pointerEvents: 'auto', + cursor, + zIndex: String(Z.toast + 2), + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(proxy, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + } + + function proxyMouseEvent(type, source, target) { + let event; + try { + event = new MouseEvent(type, { + bubbles: type !== 'mouseenter' && type !== 'mouseleave', + cancelable: true, + composed: true, + clientX: source.clientX, + clientY: source.clientY, + screenX: source.screenX, + screenY: source.screenY, + button: source.button || 0, + buttons: source.buttons || 0, + ctrlKey: source.ctrlKey, + metaKey: source.metaKey, + shiftKey: source.shiftKey, + altKey: source.altKey, + }); + target.dispatchEvent(event); + } catch {} + } + + function bindEditBadgeProxy(proxy, target) { + const stop = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + proxy.addEventListener('mouseenter', (event) => { + stop(event); + proxyMouseEvent('mouseenter', event, target); + proxyMouseEvent('mouseover', event, target); + }); + proxy.addEventListener('mouseleave', (event) => { + stop(event); + proxyMouseEvent('mouseleave', event, target); + proxyMouseEvent('mouseout', event, target); + }); + proxy.addEventListener('mousedown', (event) => { + stop(event); + target.focus?.({ preventScroll: true }); + proxyMouseEvent('mousedown', event, target); + }); + proxy.addEventListener('mouseup', (event) => { + stop(event); + proxyMouseEvent('mouseup', event, target); + }); + proxy.addEventListener('click', (event) => { + stop(event); + target.click(); + syncEditBadgeHitProxies(); + }); + } + + function editBadgeProxyTargets() { + if (!usesShadowChromeRoot() || !editBadgeEl || editBadgeEl.style.display === 'none') return []; + return [...editBadgeEl.querySelectorAll('button')].filter((target) => { + if (target.disabled) return false; + const rect = target.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return false; + const style = getComputedStyle(target); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); + } + + function syncEditBadgeHitProxies() { + if (!usesShadowChromeRoot()) { + if (editBadgeProxyRoot) editBadgeProxyRoot.remove(); + editBadgeProxyRoot = null; + editBadgeProxyByTarget = new Map(); + return; + } + initEditBadgeHitProxies(); + if (!editBadgeProxyRoot) return; + const targets = editBadgeProxyTargets(); + const active = new Set(targets); + for (const [target, proxy] of editBadgeProxyByTarget) { + if (!active.has(target) || !target.isConnected) { + proxy.remove(); + editBadgeProxyByTarget.delete(target); + } + } + for (const target of targets) { + let proxy = editBadgeProxyByTarget.get(target); + if (!proxy) { + proxy = document.createElement('button'); + proxy.type = 'button'; + proxy.tabIndex = -1; + proxy.dataset.impeccableEditBadgeProxy = 'true'; + proxy.setAttribute('aria-hidden', 'true'); + bindEditBadgeProxy(proxy, target); + editBadgeProxyRoot.appendChild(proxy); + editBadgeProxyByTarget.set(target, proxy); + } + proxy.title = target.title || target.textContent || 'Edit copy'; + styleEditBadgeProxy(proxy, target); + } + } function initEditBadge() { editBadgeEl = document.createElement('div'); @@ -3813,10 +4182,11 @@ display: 'none', userSelect: 'none', }); - document.body.appendChild(editBadgeEl); + uiAppend(editBadgeEl); + initEditBadgeHitProxies(); // Remove focus rings on edit badge buttons + contenteditable elements - if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + if (!uiGetById(PREFIX + '-edit-badge-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-edit-badge-focus-style'; s.textContent = @@ -3826,21 +4196,26 @@ '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; - document.head.appendChild(s); + uiAppendStyle(s); } } function positionEditBadge() { - if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') { + syncEditBadgeHitProxies(); + return; + } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { if (editBadgeEl) editBadgeEl.style.display = 'none'; + syncEditBadgeHitProxies(); return; } editBadgeEl.style.display = 'flex'; @@ -4047,7 +4422,7 @@ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN; } // Re-render the bar so the Tune chip picks up the active styling. - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } function closeTunePopover() { @@ -4055,13 +4430,13 @@ hideParamsPanel(); if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT; if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') { - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } } - // --------------------------------------------------------------------------- + // // Variant cycling in DOM - // --------------------------------------------------------------------------- + // function isVariantShown(el) { if (!el) return false; @@ -4081,9 +4456,42 @@ } } - function showVariantInDOM(sessionId, num) { + function scheduleCyclingBarSync(sessionId, variantNum) { + requestAnimationFrame(() => { + if (state !== 'CYCLING') return; + if (currentSessionId !== sessionId) return; + if (visibleVariant !== variantNum) return; + showOrUpdateCyclingBar(); + syncCyclingControls(); + positionBar(); + }); + } + + function syncCyclingControls() { + const shown = svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0 + ? svelteComponentSession.mountedVariant + : visibleVariant; + const counter = uiGetById(PREFIX + '-variant-counter'); + if (counter && arrivedVariants > 0) counter.textContent = shown + '/' + arrivedVariants; + const prev = uiGetById(PREFIX + '-variant-prev'); + const next = uiGetById(PREFIX + '-variant-next'); + if (prev) prev.style.opacity = shown <= 1 ? '0.3' : '1'; + if (next) next.style.opacity = shown >= arrivedVariants ? '0.3' : '1'; + if (currentSessionId && state === 'CYCLING') saveSession(); + } + + async function showVariantInDOM(sessionId, num) { + if (svelteComponentSession?.sessionId === sessionId) { + visibleVariant = num; + const mounted = await mountSvelteComponentVariant(num); + if (!mounted) return false; + updateSelectedElement(); + refreshParamsPanel(); + scheduleCyclingBarSync(sessionId, num); + return true; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); - if (!wrapper) return; + if (!wrapper) return false; for (const child of wrapper.children) { const v = child.dataset ? child.dataset.impeccableVariant : null; if (!v) continue; @@ -4093,6 +4501,378 @@ // CYCLING yet, the subsequent CYCLING transition triggers its own // refresh) and every cycle step. refreshParamsPanel(); + return true; + } + + function isSvelteComponentManifestPath(filePath) { + return String(filePath || '').endsWith('manifest.json'); + } + + function parseOriginalMarkupElement(originalMarkup) { + const parser = new DOMParser(); + const doc = parser.parseFromString('
' + originalMarkup + '
', 'text/html'); + return doc.getElementById('impeccable-anchor')?.firstElementChild || null; + } + + function findLiveElementForOriginalMarkup(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const tag = origContent.tagName.toLowerCase(); + const cls = origContent.className; + let liveEl = null; + if (origContent.id) { + liveEl = document.getElementById(origContent.id); + } else if (cls) { + const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + for (const c of candidates) { + if (c.className === cls && !own(c)) { liveEl = c; break; } + } + if (!liveEl) { + const expectedClasses = String(cls).split(/\s+/).filter(Boolean); + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } + } + } + return liveEl; + } + + function isSvelteInsertManifest(manifest) { + return manifest?.previewMode === 'svelte-component' && manifest?.mode === 'insert'; + } + + function findLiveElementForSvelteManifest(manifest) { + if (isSvelteInsertManifest(manifest)) { + const anchor = findInsertAnchorInDom(); + if (anchor?.parentElement) return anchor; + } + return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function loadSvelteRuntime(runtimeModule) { + const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js'; + const url = new URL(modulePath, location.origin).href; + if (!svelteRuntimePromise) { + svelteRuntimePromise = import(/* @vite-ignore */ url); + } + return svelteRuntimePromise; + } + + // Svelte component variants declare their params in a sidecar params.json under + // componentDir (keyed by variant number), because a `data-impeccable-params` + // attribute with JSON braces can't survive the Svelte compiler. Returns a map of + // { "1": [...params], "2": [...] }; an empty object when the agent declared none. + async function loadSvelteComponentParams(manifest) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir) return {}; + const paramsPath = dir + '/params.json'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(paramsPath); + try { + const res = await fetch(url); + if (!res.ok) return {}; + const parsed = JSON.parse(await res.text()); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out = {}; + for (const [key, value] of Object.entries(parsed)) { + if (Array.isArray(value)) out[String(key)] = value; + } + return out; + } catch { + return {}; + } + } + + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { + const contract = manifest?.propContract || []; + const values = {}; + if (!liveEl || contract.length === 0) return values; + const sourceOriginal = parseOriginalMarkupElement(manifest.originalMarkup || ''); + if (!sourceOriginal) return values; + const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl); + for (const entry of contract) { + const token = '{' + entry.expr + '}'; + values[entry.prop] = map.get(token) || ''; + } + return values; + } + + async function mountSvelteComponentVariant(variantNum) { + if (!svelteComponentSession || !variantNum) return false; + const { manifest, mountTargetEl, sessionId } = svelteComponentSession; + try { + const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement; + svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null; + const runtime = await loadSvelteRuntime(manifest.runtimeModule); + const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte'; + const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now(); + const mod = await import(/* @vite-ignore */ moduleUrl); + const Component = mod.default; + if (svelteComponentSession.mountedInstance && runtime.unmount) { + await runtime.unmount(svelteComponentSession.mountedInstance); + svelteComponentSession.mountedInstance = null; + } + svelteComponentSession.mountedInstance = runtime.mount(Component, { + target: mountTargetEl, + props: { ...svelteComponentSession.propValues }, + intro: false, + }); + svelteComponentSession.mountedVariant = variantNum; + svelteComponentSession.runtime = runtime; + if (state === 'CYCLING') syncCyclingControls(); + const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (nextAnchor) { + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(nextAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = nextAnchor; + } else { + requestAnimationFrame(() => { + if (svelteComponentSession?.sessionId !== sessionId) return; + const settledAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!settledAnchor) return; + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(settledAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = settledAnchor; + }); + } + return true; + } catch (err) { + if (svelteComponentSession?.sessionId === sessionId) { + svelteComponentSession.swapAnchor = null; + } + console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err); + return false; + } + } + + function teardownSvelteComponentSession(restoreOriginal) { + if (!svelteComponentSession) return; + const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + if (restoreOriginal && detachedOriginal && wrapperEl?.parentElement) { + wrapperEl.parentElement.replaceChild(detachedOriginal, wrapperEl); + } else if (wrapperEl?.parentElement) { + wrapperEl.remove(); + } + svelteComponentSession = null; + svelteRuntimePromise = null; + } + + function applyOriginalAttrsToSvelteAnchor(el, originalMarkup) { + if (!el || !originalMarkup) return; + const original = parseOriginalMarkupElement(originalMarkup); + if (!original || original.tagName !== el.tagName) return; + for (const attr of original.attributes) { + if (attr.name === 'class') { + for (const className of attr.value.split(/\s+/).filter(Boolean)) { + el.classList.add(className); + } + } else if (!el.hasAttribute(attr.name)) { + el.setAttribute(attr.name, attr.value); + } + } + } + + function commitAcceptedSvelteComponentToDom(sessionId) { + if (!svelteComponentSession || svelteComponentSession.sessionId !== sessionId) return false; + const { wrapperEl, runtime, mountedInstance, manifest } = svelteComponentSession; + const anchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!anchor || !wrapperEl?.parentElement) return false; + const committed = anchor.cloneNode(true); + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(committed, manifest.originalMarkup || ''); + } + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + wrapperEl.parentElement.replaceChild(committed, wrapperEl); + svelteComponentSession = null; + svelteRuntimePromise = null; + selectedElement = committed; + return true; + } + + async function injectSvelteComponentsFromManifest(manifestPath, sessionId) { + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(manifestPath); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + const manifest = JSON.parse(await res.text()); + if (manifest.id !== sessionId) return; + + const paramsByVariant = await loadSvelteComponentParams(manifest); + currentSessionId = sessionId; + expectedVariants = Number(manifest.count) || expectedVariants || 1; + rememberSessionFileMeta({ + sourceFile: manifest.sourceFile, + previewFile: manifestPath, + previewMode: 'svelte-component', + }); + if (state !== 'CYCLING') state = 'GENERATING'; + + const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { + recoveryWaitingForAnchor = false; + svelteComponentSession.paramsByVariant = paramsByVariant; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; + await mountSvelteComponentVariant(visibleVariant || 1); + state = 'CYCLING'; + showOrUpdateCyclingBar(); + saveSession(); + return; + } + + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) { + console.warn('[impeccable] Could not find original element in live DOM.'); + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants + ? visibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + saveSession(); + queueCheckpoint('svelte_component_anchor_missing'); + waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return; + } + + const wrapper = document.createElement('div'); + wrapper.dataset.impeccableVariants = sessionId; + wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1); + wrapper.dataset.impeccablePreview = 'svelte-component'; + wrapper.style.display = 'contents'; + + const mountTarget = document.createElement('div'); + mountTarget.dataset.impeccableComponentMount = sessionId; + mountTarget.style.display = 'contents'; + wrapper.appendChild(mountTarget); + + const insertMode = isSvelteInsertManifest(manifest); + const detachedOriginal = insertMode ? null : liveEl; + if (insertMode) { + removeInsertPlaceholderDom(); + if (manifest.position === 'before') liveEl.parentElement.insertBefore(wrapper, liveEl); + else liveEl.parentElement.insertBefore(wrapper, liveEl.nextSibling); + } else { + liveEl.parentElement.replaceChild(wrapper, liveEl); + } + + svelteComponentSession = { + sessionId, + manifest, + insertMode, + wrapperEl: wrapper, + mountTargetEl: mountTarget, + detachedOriginal, + mountedInstance: null, + mountedVariant: 0, + runtime: null, + propValues: buildSveltePropValuesFromLiveElement(detachedOriginal, manifest), + paramsByVariant, + }; + if (pendingSvelteComponentRetryObserver) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + } + recoveryWaitingForAnchor = false; + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + + const mounted = await mountSvelteComponentVariant(visibleVariant); + if (!mounted) { + // The compiled component threw (e.g. a Svelte compile error in the + // variant file). Don't strand the bar in an empty CYCLING state; restore + // the original element and reset to PICKING so the user can retry. + abortSvelteComponentInjection(sessionId, 'A variant failed to compile. Fix the component and re-run.'); + return; + } + + selectedElement = mountTarget.firstElementChild || mountTarget; + state = 'CYCLING'; + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.'); + } catch (err) { + console.error('[impeccable] Failed to mount Svelte component variants:', err); + abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.'); + } + } + + function waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }) { + if (pendingSvelteComponentRetryObserver) pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = new MutationObserver(() => { + if (svelteComponentSession?.sessionId === sessionId) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + return; + } + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) return; + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + injectSvelteComponentsFromManifest(manifestPath, sessionId); + }); + pendingSvelteComponentRetryObserver.observe(document.body, { childList: true, subtree: true }); + } + + // Reset cleanly when a Svelte component session can't mount: tear the wrapper + // down (restoring the original element), clear persisted session state, and + // return the bar to PICKING. Avoids the stuck 0/0 CYCLING bar. + function abortSvelteComponentInjection(sessionId, message) { + try { + if (svelteComponentSession?.sessionId === sessionId) { + teardownSvelteComponentSession(true); + } else { + const orphan = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (orphan) orphan.remove(); + } + } catch (err) { + console.warn('[impeccable] Svelte component abort cleanup failed:', err); + } + hideShaderOverlay(); + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + stopScrollLock(); + clearSession(); + clearHandled(); + resetSessionFileMeta(); + currentSessionId = null; + expectedVariants = 0; + arrivedVariants = 0; + visibleVariant = 0; + selectedElement = null; + state = 'PICKING'; + hideBar(); + if (message) showToast(message, 5000); } /** @@ -4101,6 +4881,11 @@ * This works even when the dev server caches HTML (Bun, static servers). */ function injectVariantsFromSource(filePath, sessionId) { + if (isSvelteComponentManifestPath(filePath)) { + injectSvelteComponentsFromManifest(filePath, sessionId); + return; + } + rememberSessionFileMeta({ file: filePath }); const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) @@ -4119,7 +4904,7 @@ const doc = parser.parseFromString(block, 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { - console.error('[impeccable] Variant wrapper not found in source file.'); + console.warn('[impeccable] Variant wrapper not found in source file.'); return; } @@ -4134,31 +4919,31 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; - if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - } - + const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); if (!liveEl) { - console.error('[impeccable] Could not find original element in live DOM.'); + console.warn('[impeccable] Could not find original element in live DOM.'); + selectedElement = document.body; + recoveryWaitingForAnchor = true; + state = 'GENERATING'; + showBar('generating'); + saveSession(); + showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } + recoveryWaitingForAnchor = false; // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); arrivedVariants = variants.length; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + recoverEmptyCycling('source-fallback-empty'); + return; + } const saved = loadSession(); const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants @@ -4170,8 +4955,9 @@ selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4184,21 +4970,129 @@ }); } - function cycleVariant(dir) { + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { + const map = new Map(); + if (!sourceOriginal || !liveOriginal) return map; + + const sourceNodes = collectTextNodes(sourceOriginal) + .filter((node) => /\{[^{}]+\}/.test(node.nodeValue || '')); + const liveTexts = collectTextNodes(liveOriginal) + .map((node) => normalizePreviewText(node.nodeValue || '')) + .filter(Boolean); + let liveIndex = 0; + + for (const sourceNode of sourceNodes) { + const sourceText = sourceNode.nodeValue || ''; + const tokens = sourceText.match(/\{[^{}]+\}/g) || []; + if (tokens.length === 0) continue; + + const liveText = liveTexts[liveIndex++] || ''; + if (!liveText) continue; + + if (tokens.length === 1) { + const token = tokens[0]; + const normalizedSource = normalizePreviewText(sourceText); + if (normalizedSource === token) { + map.set(token, liveText); + continue; + } + + const match = liveText.match(expressionTextMatcher(sourceText, [token])); + if (match && match[1]) map.set(token, match[1].trim()); + continue; + } + + if (normalizePreviewText(sourceText) === tokens.join(' ')) { + for (const token of tokens) { + const tokenLiveText = liveTexts[liveIndex - 1] || ''; + if (tokenLiveText) map.set(token, tokenLiveText); + } + } + } + + return map; + } + + function expressionTextMatcher(sourceText, tokens) { + let pattern = '^'; + let cursor = 0; + for (const token of tokens) { + const index = sourceText.indexOf(token, cursor); + if (index === -1) continue; + pattern += escapeRegExp(sourceText.slice(cursor, index)).replace(/\s+/g, '\\s*'); + pattern += '(.*?)'; + cursor = index + token.length; + } + pattern += escapeRegExp(sourceText.slice(cursor)).replace(/\s+/g, '\\s*') + '$'; + return new RegExp(pattern); + } + + function collectTextNodes(root) { + if (!root) return []; + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + nodes.push(node); + node = walker.nextNode(); + } + return nodes; + } + + function normalizePreviewText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + async function selectVariant(next, checkpointReason) { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - const next = visibleVariant + dir; + if (variantSelectionInFlight) return; if (next < 1 || next > arrivedVariants) return; - visibleVariant = next; - showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself - updateSelectedElement(); - updateBarContent('cycling'); - positionBar(); - saveSession(); - queueCheckpoint('variant_changed'); + if (next === visibleVariant) return; + + const previous = visibleVariant; + variantSelectionInFlight = true; + const selectionPromise = (async () => { + visibleVariant = next; + showOrUpdateCyclingBar(); + saveSession(); + const shown = await showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself + if (!shown) { + visibleVariant = previous; + await showVariantInDOM(currentSessionId, previous); + showOrUpdateCyclingBar(); + saveSession(); + return; + } + updateSelectedElement(); + showOrUpdateCyclingBar(); + positionBar(); + saveSession(); + if (checkpointReason) queueCheckpoint(checkpointReason); + })(); + variantSelectionPromise = selectionPromise; + try { + await selectionPromise; + } finally { + if (variantSelectionPromise === selectionPromise) variantSelectionPromise = null; + variantSelectionInFlight = false; + } + } + + function cycleVariant(dir) { + selectVariant(visibleVariant + dir, 'variant_changed'); } function updateSelectedElement() { if (!currentSessionId) return; + if (svelteComponentSession?.sessionId === currentSessionId) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; + return; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); @@ -4206,6 +5100,9 @@ } function readVisibleVariantFromDOM(sessionId) { + if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { + return svelteComponentSession.mountedVariant; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -4341,9 +5238,9 @@ // scrollY that the next resume needs to read. } - // --------------------------------------------------------------------------- + // // MutationObserver for progressive variant reveal - // --------------------------------------------------------------------------- + // function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -4426,10 +5323,11 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); updateSelectedElement(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4445,9 +5343,9 @@ return obs; } - // --------------------------------------------------------------------------- + // // Bar scroll tracking - // --------------------------------------------------------------------------- + // function startScrollTracking() { function tick() { @@ -4483,10 +5381,10 @@ if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; } } - // --------------------------------------------------------------------------- + // // SSE (server→browser) + fetch POST (browser→server) // Zero-dependency replacement for WebSocket. - // --------------------------------------------------------------------------- + // let evtSource = null; let sseRetries = 0; @@ -4509,6 +5407,7 @@ console.log('[impeccable] Live mode connected.'); syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); + restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; syncPageChatFocus('sse-connected'); break; @@ -4531,11 +5430,12 @@ break; case 'done': if (maybeCompleteSteer(msg)) break; + rememberSessionFileMeta(msg); // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); } @@ -4557,7 +5457,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; if (state !== 'GENERATING') return; showToast( - "Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.", + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", 15000, ); }, 2000); @@ -4571,12 +5471,18 @@ // the final complete event. Keep the browser in its recoverable // saving state while the source cleanup is still in flight. break; + case 'discarded': + if (msg.id && msg.id === currentSessionId) { + markSessionHandled(); + cleanup(); + } + break; case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; state = 'CYCLING'; updateBarContent('cycling'); - showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000); + showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; } if (maybeCompleteSteer(msg)) break; @@ -4656,6 +5562,9 @@ expectedVariants, arrivedVariants, visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, paramValues: { ...paramsCurrentValues }, }; } @@ -4665,6 +5574,20 @@ return sendEvent(checkpointPayload(reason)).catch(() => null); } + function sendSteerCheckpoint(id, reason, extra) { + if (!id) return Promise.resolve(null); + return sendEvent({ + type: 'checkpoint', + id, + revision: sessionState.nextCheckpointRevision(), + owner: browserOwner, + phase: 'steer', + reason, + pageUrl: location.pathname, + ...(extra || {}), + }).catch(() => null); + } + function queueCheckpoint(reason) { if (!currentSessionId) return; if (checkpointTimer) clearTimeout(checkpointTimer); @@ -4674,9 +5597,9 @@ }, 120); } - // --------------------------------------------------------------------------- + // // Event handlers - // --------------------------------------------------------------------------- + // function handleMouseMove(e) { if (pendingApplyInFlight) return; @@ -4860,7 +5783,7 @@ // // DISABLED: quick-Go workflows pay an extra harness round trip because // prefetch + generate arrive as two events instead of one. Re-enable with - // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to + // a browser-side debounce (~800-1000ms, cancelled on Go) if we want to // resurrect this. Server validator and skill dispatch remain in place so // flipping this flag is the only change needed. const PREFETCH_ENABLED = false; @@ -4876,6 +5799,14 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + const deepActive = activeElementDeep(); + if ( + deepActive + && own(deepActive) + && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + ) { + return; + } // While a contenteditable text-leaf is focused, let the browser handle // all keys except Escape. Escape cancels the current edit (restores // original text) and blurs without saving, staying in CONFIGURING. @@ -4982,7 +5913,7 @@ if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!selectedElement || state !== 'CONFIGURING') return; stopVoice({ suppressSubmit: true }); - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; // Commit any pending pin edit BEFORE we snapshot annotations. @@ -4996,6 +5927,7 @@ expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); // Flip to GENERATING immediately so the bar morphs without waiting on // capture + upload. The event is emitted from captureAndEmit() once the @@ -5054,7 +5986,7 @@ function handleInsertCreate() { if (!placeholderElement || !insertAnchorElement || state !== 'CONFIGURING' || configureKind !== 'insert') return; - const input = document.getElementById(PREFIX + '-insert-input'); + const input = uiGetById(PREFIX + '-insert-input'); const prompt = input ? input.value.trim() : ''; if (annotEditing) finalizeEditingPin(); const snapshot = { @@ -5064,10 +5996,12 @@ if (!canCreateInsert({ prompt, comments: snapshot.comments, strokes: snapshot.strokes })) return; stopVoice({ suppressSubmit: true }); + pendingAcceptedSession = null; currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); selectedElement = placeholderElement; insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement); @@ -5107,9 +6041,9 @@ captureAndEmit(elForCapture, basePayload, snapshot, captureRect); } - // --------------------------------------------------------------------------- + // // Screenshot capture + upload - // --------------------------------------------------------------------------- + // let msLoadPromise = null; function loadModernScreenshot() { @@ -5120,7 +6054,7 @@ s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; s.onload = () => resolve(window.modernScreenshot); s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; - document.head.appendChild(s); + uiAppendStyle(s); }); return msLoadPromise; } @@ -5235,11 +6169,113 @@ return '#ffffff'; } + function captureChromeNodes() { + const nodes = []; + const add = (node) => { + if (!node || node === document.body || nodes.includes(node)) return; + nodes.push(node); + }; + add(document.getElementById(PREFIX + '-root')); + [ + PREFIX + '-highlight', + PREFIX + '-tooltip', + PREFIX + '-bar', + PREFIX + '-picker', + PREFIX + '-params-panel', + PREFIX + '-insert-line', + PREFIX + '-insert-placeholder', + PREFIX + '-insert-create-tooltip', + PREFIX + '-annot', + PREFIX + '-design-host', + PREFIX + '-toast', + PREFIX + '-shader', + ].forEach((id) => add(uiGetById(id))); + return nodes; + } + + async function hideCaptureChromeForShaderProxy(fn) { + const saved = captureChromeNodes().map((node) => ({ + node, + visibility: node.style.visibility, + priority: node.style.getPropertyPriority('visibility'), + })); + for (const { node } of saved) { + node.style.setProperty('visibility', 'hidden', 'important'); + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + try { + return await fn(); + } finally { + for (const { node, visibility, priority } of saved) { + node.style.setProperty('visibility', visibility, priority); + } + } + } + + function shouldUseAncestorCropShaderProxy(el) { + // TODO: Enable this proxy for React/Vue/etc. adapters once their live + // preview mounts are covered by the same shader regression checks. + const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase(); + if (adapter === 'svelte' || adapter === 'sveltekit') return true; + if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true; + const wrapper = el?.closest?.('[data-impeccable-variants]'); + return wrapper?.dataset?.impeccablePreview === 'svelte-component'; + } + + function paintsShaderProxySurface(node) { + const s = getComputedStyle(node); + return !isTransparentColor(s.backgroundColor) + || (s.backgroundImage && s.backgroundImage !== 'none') + || paintsBackdrop(node); + } + + function findShaderProxyCaptureRoot(el) { + const doc = el.ownerDocument || document; + const er = el.getBoundingClientRect(); + let node = el.parentElement; + while (node && node !== doc.documentElement) { + const nr = node.getBoundingClientRect(); + const containsElement = + nr.width > 0 && nr.height > 0 && + nr.left <= er.left + 0.5 && + nr.top <= er.top + 0.5 && + nr.right >= er.right - 0.5 && + nr.bottom >= er.bottom - 0.5; + if (containsElement && paintsShaderProxySurface(node)) return node; + node = node.parentElement; + } + return null; + } + // Capture the element (with current annotations baked in) and return // { blob, paper }: the PNG Blob, plus the representative backdrop tone for the // shader's halftone ground (so capture, upload, and shader all agree on what // sits behind the element). Shared between the Go flow (uploads the blob) and // the shader-resume path. + async function captureElementFromRenderedAncestor(ms, el, opts) { + const doc = el.ownerDocument || document; + const captureRoot = findShaderProxyCaptureRoot(el); + if (!captureRoot) throw new Error('No painted ancestor for Svelte shader proxy'); + const rootCanvas = await ms.domToCanvas(captureRoot, opts); + const S = opts.scale; + const er = el.getBoundingClientRect(); + const rr = captureRoot.getBoundingClientRect(); + const sx = (er.left - rr.left) * S; + const sy = (er.top - rr.top) * S; + const sw = er.width * S; + const sh = er.height * S; + if (sw <= 0 || sh <= 0) throw new Error('Selected element has no visible capture rect'); + const crop = doc.createElement('canvas'); + crop.width = Math.max(1, Math.round(sw)); + crop.height = Math.max(1, Math.round(sh)); + const cctx = crop.getContext('2d', { willReadFrequently: true }); + cctx.drawImage(rootCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height); + const paper = dominantRgb01(cctx, crop.width, crop.height) || averageRgb01(cctx, crop.width, crop.height); + const blob = await new Promise((res) => crop.toBlob(res, 'image/png')); + if (!blob) throw new Error('Ancestor crop failed to produce a PNG blob'); + return { blob, paper }; + } + async function captureElementToBlob(el, snapshot, rect) { try { if (document.fonts?.ready) await document.fonts.ready; } catch {} const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); @@ -5261,6 +6297,13 @@ scale: Math.min(window.devicePixelRatio || 1, 2), font: fontCssText ? { cssText: fontCssText } : undefined, }; + if (shouldUseAncestorCropShaderProxy(el)) { + try { + return await hideCaptureChromeForShaderProxy(() => captureElementFromRenderedAncestor(ms, el, opts)); + } catch (err) { + console.warn('[impeccable] Svelte ancestor crop capture failed, falling back to element capture:', err); + } + } const bg = resolveCanvasBackground(el); // Fast path: the element paints its own background, or an opaque ancestor // color was found. modern-screenshot bakes that color; paper matches it. @@ -5342,13 +6385,13 @@ sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); } - // --------------------------------------------------------------------------- + // // Shader overlay - renders the captured screenshot as a WebGL texture and // runs an editorial "ink-wash" fragment shader over it during generation. // A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku // and leaving a soft trail. Makes the wait feel like a letterpress scan // instead of a dead spinner. - // --------------------------------------------------------------------------- + // const SHADER_VS = `attribute vec2 a_position; attribute vec2 a_uv; @@ -5502,6 +6545,31 @@ void main() { return n ? [r / n / 255, g / n / 255, b / n / 255] : SHADER_PAPER_FALLBACK; } + // Pick the most common visible color cluster from a crop. A straight average + // gets pulled by text and icons; the dominant bucket usually represents the + // surface the shader should dissolve into. + function dominantRgb01(ctx, w, h) { + const data = ctx.getImageData(0, 0, w, h).data; + const stride = Math.max(1, Math.floor((w * h) / 6000)); + const buckets = new Map(); + for (let p = 0; p < w * h; p += stride) { + const i = p * 4; + if (data[i + 3] < 16) continue; + const key = (data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4); + const bucket = buckets.get(key) || { count: 0, r: 0, g: 0, b: 0 }; + bucket.count += 1; + bucket.r += data[i]; + bucket.g += data[i + 1]; + bucket.b += data[i + 2]; + buckets.set(key, bucket); + } + let best = null; + for (const bucket of buckets.values()) { + if (!best || bucket.count > best.count) best = bucket; + } + return best ? [best.r / best.count / 255, best.g / best.count / 255, best.b / best.count / 255] : null; + } + // Average the backdrop sampled just OUTSIDE an element's rect within a larger // canvas. The ground tone for the dissolve must be the real backdrop, not the // mean of the element's own crop - averaging the crop folds in the element's @@ -5572,7 +6640,7 @@ void main() { fallback.style.backgroundRepeat = 'no-repeat'; fallback.style.outline = '2px dashed ' + C.brand; fallback.style.outlineOffset = '-2px'; - document.body.appendChild(fallback); + uiAppend(fallback); shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl }; } @@ -5582,16 +6650,19 @@ void main() { const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); + const radius = getComputedStyle(el).borderRadius; canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); Object.assign(canvas.style, { position: 'fixed', top: rect.top + 'px', left: rect.left + 'px', width: rect.width + 'px', height: rect.height + 'px', + borderRadius: radius, + overflow: 'hidden', pointerEvents: 'none', zIndex: Z.bar - 1, }); - document.body.appendChild(canvas); + uiAppend(canvas); const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) || canvas.getContext('experimental-webgl'); @@ -5685,8 +6756,12 @@ void main() { frame(); } - function handleAccept() { + async function handleAccept() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } + if (pendingAcceptedSession || state === 'SAVING') return; + if (variantSelectionPromise) { + try { await variantSelectionPromise; } catch { /* failed selection falls back below */ } + } if (!currentSessionId || arrivedVariants === 0) return; const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId); if (domVisibleVariant > 0) visibleVariant = domVisibleVariant; @@ -5696,30 +6771,39 @@ void main() { variantId: String(visibleVariant), pageUrl: location.pathname, }; + const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } + // The accepted variant is already the only visible child of the wrapper + // (all other variants are display:none). HMR from the source rewrite will + // replace the wrapper imminently. Don't eagerly replaceChild here - React + // reconciliation races with our mutation and throws NotFoundError in Next + // 16 / Turbopack. Schedule a fallback that runs the manual swap only if + // HMR hasn't cleaned up by then (keeps static-server flows working). const acceptedSessionId = currentSessionId; const acceptedVariant = visibleVariant; + const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId + || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - pendingAcceptedSession = { - id: acceptedSessionId, - variant: String(acceptedVariant), - ...acceptedSnapshot, - finalizing: false, - }; state = 'SAVING'; updateBarContent('saving'); + pendingAcceptedSession = { + id: acceptedSessionId, + variant: String(acceptedVariant), + isSvelteComponent: acceptedIsSvelteComponent, + ...acceptedSnapshot, + finalizing: false, + }; + saveSession(); sendEvent(acceptPayload, { throwOnError: true }) - .then(() => { - markSessionHandled(); - }) + .then(() => {}) .catch(() => { - pendingAcceptedSession = null; + if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); } @@ -5733,19 +6817,21 @@ void main() { } if (pending.finalizing) return true; pending.finalizing = true; - + markSessionHandled(); + if (pending.isSvelteComponent) { + commitAcceptedSvelteComponentToDom(pending.id); + } state = 'CONFIRMED'; updateBarContent('confirmed'); + scheduleAcceptCleanup(pending); + return true; + } - // Give framework HMR a short chance to render the now-clean accepted - // source. If it misses the update, unwrap the accepted variant after the - // source-side completion event so the page is not left empty or stale. + function scheduleAcceptCleanup(accepted) { setTimeout(function() { - ensureAcceptedDomClean(pending); + if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted); cleanupAcceptedSession(); }, 1200); - - return true; } function snapshotAcceptedVariantDom(sessionId, variantId) { @@ -5833,6 +6919,7 @@ void main() { stopScrollLock(); clearScrollY(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5841,6 +6928,28 @@ void main() { state = 'PICKING'; } + function commitAcceptedVariantToDom(sessionId, variantId) { + const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapper) return false; + const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); + if (!accepted || !accepted.firstElementChild) return false; + const parent = wrapper.parentElement; + if (!parent) return false; + + const style = wrapper.querySelector('style[data-impeccable-css]'); + if (style && !document.querySelector('style[data-impeccable-accepted-css="' + sessionId + '"]')) { + const promotedStyle = style.cloneNode(true); + promotedStyle.setAttribute('data-impeccable-accepted-css', sessionId); + parent.insertBefore(promotedStyle, wrapper); + } + + const committed = accepted.cloneNode(true); + committed.removeAttribute('hidden'); + committed.style.display = 'contents'; + parent.replaceChild(committed, wrapper); + return true; + } + function handleDiscard() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!currentSessionId) return; @@ -5852,11 +6961,141 @@ void main() { .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000)); } - // --------------------------------------------------------------------------- + // // Session persistence via live-browser-session.js - // --------------------------------------------------------------------------- + // // Survives page reloads, browser close/reopen, HMR, and accidental refreshes. + function normalizeSessionPath(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed.replace(/\\/g, '/') : null; + } + + function resetSessionFileMeta() { + currentSourceFile = null; + currentPreviewFile = null; + currentPreviewMode = null; + recoveryWaitingForAnchor = false; + } + + function rememberSessionFileMeta(meta = {}) { + const file = normalizeSessionPath(meta.file); + const sourceFile = normalizeSessionPath(meta.sourceFile); + const previewFile = normalizeSessionPath(meta.previewFile); + const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null); + + if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) { + currentPreviewMode = 'svelte-component'; + currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile); + currentSourceFile = sourceFile || currentSourceFile; + return; + } + + if (sourceFile || file) currentSourceFile = sourceFile || file; + if (previewFile) currentPreviewFile = previewFile; + if (previewMode) currentPreviewMode = previewMode; + } + + function applySavedSessionMeta(saved) { + if (!saved) return; + rememberSessionFileMeta(saved); + if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.action) selectedAction = saved.action; + if (saved.count) selectedCount = saved.count; + if (saved.previewMode) currentPreviewMode = saved.previewMode; + if (saved.paramValues && typeof saved.paramValues === 'object') { + paramsCurrentValues = { ...saved.paramValues }; + } + } + + function normalizePagePath(value) { + if (!value || typeof value !== 'string') return null; + try { + return new URL(value, location.origin).pathname; + } catch { + return value.split(/[?#]/)[0] || null; + } + } + + function pageMatchesCurrent(value) { + const path = normalizePagePath(value); + return !path || path === location.pathname; + } + + function isTerminalSessionSummary(session) { + return /^(completed|discarded|discard_requested|accept_requested)$/.test(String(session?.phase || '')); + } + + function findActiveSessionSummary(saved, activeSessions) { + if (!saved?.id || !Array.isArray(activeSessions)) return null; + return activeSessions.find((session) => + session?.id === saved.id + && pageMatchesCurrent(session.pageUrl || saved.pageUrl) + && !isTerminalSessionSummary(session) + ) || null; + } + + function clampVariantIndex(value, count) { + const num = Number(value); + const max = Number(count); + if (!Number.isFinite(num) || num < 1) return 0; + if (Number.isFinite(max) && max > 0 && num > max) return 0; + return Math.floor(num); + } + + function restoreSessionWithoutWrapper(reason, activeSessions) { + const saved = loadSession(); + if (!saved?.id || isSessionHandled(saved.id)) return false; + const savedState = String(saved.state || '').toUpperCase(); + if (savedState !== 'GENERATING' && savedState !== 'CYCLING') return false; + + const serverSession = findActiveSessionSummary(saved, activeSessions); + if (Array.isArray(activeSessions) && activeSessions.length > 0 && !serverSession) { + return false; + } + + currentSessionId = saved.id; + applySavedSessionMeta(serverSession); + applySavedSessionMeta(saved); + + expectedVariants = Number(saved.expected || serverSession?.expectedVariants || selectedCount || 0); + arrivedVariants = Number(saved.arrived || serverSession?.arrivedVariants || 0); + if (arrivedVariants <= 0 && currentPreviewFile) arrivedVariants = Number(serverSession?.expectedVariants || saved.expected || selectedCount || 0); + if (expectedVariants <= 0) expectedVariants = Number(serverSession?.expectedVariants || arrivedVariants || selectedCount || 0); + visibleVariant = clampVariantIndex(saved.visible, arrivedVariants || expectedVariants) + || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) + || (arrivedVariants > 0 ? 1 : 0); + + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + if (variantObserver) variantObserver.disconnect(); + variantObserver = startVariantObserver(currentSessionId); + saveSession(); + queueCheckpoint(reason || 'browser_restore_without_wrapper'); + + const restoreFile = currentPreviewMode === 'svelte-component' + ? currentPreviewFile + : (currentSourceFile || currentPreviewFile); + if (restoreFile) { + injectVariantsFromSource(restoreFile, currentSessionId); + return true; + } + + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return true; + } + + function restoreFromActiveSessions(activeSessions, reason) { + const wrapper = document.querySelector('[data-impeccable-variants]'); + if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false; + if (svelteComponentSession?.sessionId === currentSessionId) return false; + return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); + } + function saveSession() { if (!currentSessionId) return; // NOTE: scrollY is stored under a separate key (writeScrollY). Storing @@ -5869,6 +7108,11 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, + pageUrl: location.pathname, + paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, }); } @@ -5898,31 +7142,33 @@ void main() { } function cleanup() { - // Hide the wrapper immediately so variants disappear. DON'T structurally - // mutate the DOM yet - HMR from the agent's source rewrite is on its way, - // and a manual replaceChild under React causes NotFoundError when the - // reconciler later tries to remove a wrapper we already removed. - // Schedule a 2s fallback that does the manual swap only if HMR hasn't - // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const cleanupSessionId = currentSessionId; - if (cleanupSessionId) { + if (svelteComponentSession?.sessionId === cleanupSessionId) { + teardownSvelteComponentSession(true); + } else if (cleanupSessionId) { + // Hide the wrapper immediately so variants disappear. DON'T structurally + // mutate the DOM yet - HMR from the agent's source rewrite is on its way, + // and a manual replaceChild under React causes NotFoundError when the + // reconciler later tries to remove a wrapper we already removed. + // Schedule a 2s fallback that does the manual swap only if HMR hasn't + // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); if (wrapper) wrapper.style.display = 'none'; - } - setTimeout(function() { - if (!cleanupSessionId) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!wrapper) return; - const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - wrapper.parentElement.replaceChild(content, wrapper); - return; + setTimeout(function() { + if (!cleanupSessionId) return; + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) return; + const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); + if (orig) { + const content = orig.firstElementChild; + if (content) { + lateWrapper.parentElement.replaceChild(content, lateWrapper); + return; + } } - } - wrapper.remove(); - }, 2000); + lateWrapper.remove(); + }, 2000); + } hideBar(); hideHighlight(); stopScrollTracking(); @@ -5931,6 +7177,7 @@ void main() { clearScrollY(); finalizeInsertSession(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5938,9 +7185,9 @@ void main() { state = 'PICKING'; } - // --------------------------------------------------------------------------- + // // Toast - // --------------------------------------------------------------------------- + // function showToast(message, duration) { if (toastEl) toastEl.remove(); @@ -5964,7 +7211,7 @@ void main() { }); toastEl.id = PREFIX + '-toast'; toastEl.textContent = message; - document.body.appendChild(toastEl); + uiAppend(toastEl); requestAnimationFrame(() => { toastEl.style.opacity = '1'; toastEl.style.transform = 'translateX(-50%) translateY(0)'; @@ -5978,22 +7225,70 @@ void main() { }, duration); } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. function resumeSession() { const wrapper = document.querySelector('[data-impeccable-variants]'); - if (!wrapper) { clearSession(); clearHandled(); return false; } + if (!wrapper) { + if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } const sessionId = wrapper.dataset.impeccableVariants; // Don't resume if this session was already accepted/discarded if (isSessionHandled(sessionId)) return false; + // Svelte component sessions can't be resumed by counting DOM children: the + // wrapper holds a single mount target, not [data-impeccable-variant] nodes, + // and a page reload unmounts every compiled variant. Counting children here + // would strand the bar in CYCLING at 0/0. If there's no live in-memory mount + // for this wrapper, it's an orphan (reload / failed mount): drop it and let + // the live-server's SSE re-inject the manifest if the session is still live. + if (wrapper.dataset.impeccablePreview === 'svelte-component' + && svelteComponentSession?.sessionId !== sessionId) { + wrapper.remove(); + if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } + + if (wrapper.dataset.impeccablePreview === 'svelte-component') { + if (!svelteComponentSession?.mountedVariant) { + return true; + } + currentSessionId = sessionId; + expectedVariants = Number(wrapper.dataset.impeccableVariantCount) + || Number(svelteComponentSession.manifest?.count) + || expectedVariants + || 1; + arrivedVariants = expectedVariants; + const saved = loadSession(); + applySavedSessionMeta(saved); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = svelteComponentSession.mountedVariant > 0 && svelteComponentSession.mountedVariant <= arrivedVariants + ? svelteComponentSession.mountedVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = resolveSvelteComponentAnchor() + || wrapper.parentElement; + state = 'CYCLING'; + hideShaderOverlay(); + showBar('cycling'); + startScrollTracking(); + refreshParamsPanel(); + saveSession(); + queueCheckpoint('browser_resumed_svelte_component'); + return true; + } + currentSessionId = sessionId; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -6002,6 +7297,7 @@ void main() { // Restore state from localStorage if available const saved = loadSession(); if (saved && saved.id === sessionId) { + applySavedSessionMeta(saved); visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0); if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; @@ -6072,9 +7368,9 @@ void main() { return true; } - // --------------------------------------------------------------------------- + // // Global bar (always visible at bottom) - // --------------------------------------------------------------------------- + // let globalBarEl = null; let globalBarBrandEl = null; @@ -6166,6 +7462,8 @@ void main() { let pageChatExpanded = false; let steerLocked = false; let steerRequestId = null; + let steerPendingMessage = ''; + let steerInputWasFocused = false; let pageChatDotsEl = null; let steerAwaitTimer = null; let voiceRecognition = null; @@ -6179,7 +7477,7 @@ void main() { const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; - const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect'; + const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect'; const GLOBAL_BAR_SECTION_GAP = 8; const GLOBAL_BAR_INNER_GAP = 2; const GLOBAL_BAR_INNER_PAD_LEFT = 2; @@ -6323,7 +7621,7 @@ void main() { const attempt = () => { steerFocusRecoverTimer = null; if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (pageHasHostTextSelection()) { steerFocusRecoverTimer = setTimeout(attempt, 120); return; @@ -6344,7 +7642,7 @@ void main() { steerFocusSuspended = true; steerFocusPauseUntil = performance.now() + STEER_PAGE_FOCUS_PAUSE_MS; pagePointerGesture = { x: e.clientX, y: e.clientY, dragged: false }; - if (pageChatInput && document.activeElement === pageChatInput) { + if (pageChatInput && activeElementDeep() === pageChatInput) { pageChatInput.blur(); } } @@ -6404,7 +7702,7 @@ void main() { pickActive, pageChatReady: !!pageChatInput, pageChatExpanded, - active: steerFocusTargetLabel(document.activeElement), + active: steerFocusTargetLabel(activeElementDeep()), shouldSteer: shouldFocusSteerChat(), ...(extra || {}), }); @@ -6423,26 +7721,26 @@ void main() { function focusConfigureInput(reason) { steerFocusLog('focusConfigureInput', { reason }); const inputId = configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input'; - const input = document.getElementById(inputId); + const input = uiGetById(inputId); if (!input) { steerFocusLog('focusConfigureInput missing', { reason }); return; } setTimeout(() => { - const before = document.activeElement; + const before = activeElementDeep(); input.focus(); steerFocusLog('focusConfigureInput result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== input, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== input, }); }, 60); } function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; - const focused = document.activeElement === pageChatInput; + const focused = activeElementDeep() === pageChatInput; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; const P = pageChatPalette(); pageChatEl.style.borderColor = steerLocked @@ -6476,15 +7774,15 @@ void main() { } syncPageChatVisual(); pageChatInput.style.pointerEvents = 'auto'; - const before = document.activeElement; + const before = activeElementDeep(); try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== pageChatInput, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== pageChatInput, }); } @@ -6515,6 +7813,37 @@ void main() { return wrap; } + function keepSteerPointerInside(e, opts = {}) { + e.stopPropagation(); + if (opts.preventDefault !== false) e.preventDefault(); + } + + function preparePageChatInputForTyping() { + if (!pageChatEl || !pageChatInput) return false; + pageChatExpanded = true; + pageChatEl.dataset.expanded = 'true'; + pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; + pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return true; + } + + function focusPageChatInput(reason) { + if (!preparePageChatInputForTyping() || steerLocked) return false; + try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } + const focused = activeElementDeep() === pageChatInput; + if (focused) steerInputWasFocused = true; + syncPageChatFocusRing(); + return focused; + } + function clearSteerAwaitTimer() { if (steerAwaitTimer) { clearTimeout(steerAwaitTimer); @@ -6528,6 +7857,7 @@ void main() { if (!steerLocked || steerRequestId !== id) return; unlockSteerChat({ error: 'Steer timed out waiting for the agent. Check that live-poll is running and replies with steer_done.', + restoreMessage: steerPendingMessage, }); }, STEER_AWAIT_TIMEOUT_MS); } @@ -6538,19 +7868,12 @@ void main() { steerLocked = true; pageChatEl.dataset.processing = 'true'; pageChatInput.disabled = true; - pageChatInput.value = ''; - pageChatInput.blur(); + preparePageChatInputForTyping(); if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = true; pageChatVoiceBtn.style.display = 'none'; } - pageChatExpanded = false; - pageChatEl.dataset.expanded = 'false'; - pageChatEl.style.width = PAGE_CHAT_PROCESSING_W; pageChatEl.style.cursor = 'default'; - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; pageChatInput.style.pointerEvents = 'none'; if (pageChatHint) { pageChatHint.style.display = 'none'; @@ -6568,17 +7891,26 @@ void main() { function unlockSteerChat(opts) { clearSteerAwaitTimer(); + const restoreMessage = typeof opts?.restoreMessage === 'string' ? opts.restoreMessage : ''; + const keepExpanded = Boolean(opts?.error && restoreMessage); steerLocked = false; + const completedId = steerRequestId; steerRequestId = null; if (!pageChatEl) return; pageChatEl.dataset.processing = 'false'; pageChatEl.removeAttribute('aria-busy'); pageChatEl.setAttribute('aria-label', 'Steer the page'); - pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W; + pageChatExpanded = keepExpanded; + pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false'; + pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W; pageChatEl.style.cursor = 'pointer'; if (pageChatInput) { pageChatInput.disabled = false; - pageChatInput.value = ''; + pageChatInput.value = keepExpanded ? restoreMessage : ''; + pageChatInput.style.width = keepExpanded ? '' : '0'; + pageChatInput.style.padding = keepExpanded ? '0 6px' : '0'; + pageChatInput.style.opacity = keepExpanded ? '1' : '0'; + pageChatInput.style.pointerEvents = 'auto'; } if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = false; @@ -6586,18 +7918,28 @@ void main() { } if (pageChatHint) { pageChatHint.textContent = 'Steer'; - pageChatHint.style.display = ''; - pageChatHint.style.visibility = ''; + pageChatHint.style.display = keepExpanded ? 'none' : ''; + pageChatHint.style.visibility = keepExpanded ? 'hidden' : ''; + pageChatHint.style.opacity = keepExpanded ? '0' : '1'; } if (pageChatDotsEl?.parentNode) { pageChatDotsEl.remove(); pageChatDotsEl = null; } + steerPendingMessage = keepExpanded ? restoreMessage : ''; + steerInputWasFocused = false; syncPageChatChrome(); syncPageChatFocusRing(); if (opts?.error) showToast(String(opts.error), 5000); else if (opts?.message) showToast(String(opts.message), 4000); - syncPageChatFocus('steer-unlock'); + if (completedId) { + sendSteerCheckpoint(completedId, opts?.error ? 'steer_error' : 'steer_done', { + message: opts?.message || opts?.error || '', + file: opts?.file || '', + }); + } + if (keepExpanded) focusPageChatInput('steer-error-restore'); + else syncPageChatFocus('steer-unlock'); } function steerSpeechRecognitionCtor() { @@ -6651,7 +7993,7 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = document.getElementById(PREFIX + '-configure-voice'); + const voiceBtn = uiGetById(PREFIX + '-configure-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -6784,7 +8126,7 @@ void main() { } function configureVoiceContext() { - const input = document.getElementById( + const input = uiGetById( configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input', ); return { @@ -6819,26 +8161,37 @@ void main() { if (!text || steerLocked) return; const id = id8(); steerRequestId = id; + steerPendingMessage = text; + if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); lockSteerChat(); scheduleSteerAwaitTimeout(id); + sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); sendEvent({ type: 'steer', id, message: text, pageUrl: location.href, }).then((res) => { - if (!res) unlockSteerChat({ error: 'Could not reach live server' }); + if (!res) { + sendSteerCheckpoint(id, 'steer_send_failed', { message: text }); + unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text }); + } }); } function maybeCompleteSteer(msg) { if (!steerRequestId || msg.id !== steerRequestId) return false; if (msg.type === 'steer_done') { - unlockSteerChat({ message: msg.message }); + unlockSteerChat({ message: msg.message, file: msg.file }); + if (msg.file && /\.svelte(?:$|\?)/.test(String(msg.file))) { + setTimeout(() => { + if (!steerLocked) showToast('Steer applied. Reload if the page has not refreshed yet.', 5000); + }, 4500); + } return true; } if (msg.type === 'error') { - unlockSteerChat({ error: msg.message || 'Steer failed' }); + unlockSteerChat({ error: msg.message || 'Steer failed', restoreMessage: steerPendingMessage }); return true; } return false; @@ -6847,21 +8200,10 @@ void main() { function expandPageChat(opts) { const focus = !opts || opts.focus !== false; if (!pageChatEl || !pageChatInput || steerLocked) return; - pageChatExpanded = true; - pageChatEl.dataset.expanded = 'true'; - pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; - pageChatEl.style.cursor = 'text'; - if (pageChatHint) { - pageChatHint.style.display = 'none'; - pageChatHint.style.opacity = '0'; - } - pageChatInput.style.width = ''; - pageChatInput.style.padding = '0 6px'; - pageChatInput.style.opacity = '1'; - pageChatInput.style.pointerEvents = 'auto'; + preparePageChatInputForTyping(); syncPageChatChrome(); syncPageChatFocusRing(); - if (focus) pageChatInput.focus(); + if (focus) focusPageChatInput('expand-page-chat'); } function collapsePageChat(opts) { @@ -6878,7 +8220,7 @@ void main() { } else { pageChatInput.style.pointerEvents = 'auto'; } - if (pageChatHint && document.activeElement !== pageChatInput) { + if (pageChatHint && activeElementDeep() !== pageChatInput) { pageChatHint.style.display = ''; pageChatHint.style.opacity = '1'; } @@ -6952,7 +8294,7 @@ void main() { pageChatEl.appendChild(pageChatInput); pageChatEl.appendChild(pageChatVoiceBtn); - if (!document.getElementById(PREFIX + '-page-chat-style')) { + if (!uiGetById(PREFIX + '-page-chat-style')) { const s = document.createElement('style'); s.id = PREFIX + '-page-chat-style'; s.textContent = @@ -6966,23 +8308,34 @@ void main() { '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } - pageChatEl.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { + keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat(); + expandPageChat({ focus: false }); + focusPageChatInput('page-chat-click'); }); - pageChatVoiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); + pageChatVoiceBtn.addEventListener('mousedown', keepSteerPointerInside); pageChatVoiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); + keepSteerPointerInside(e); if (steerLocked) return; toggleSteerVoice(); }); + pageChatInput.addEventListener('pointerdown', keepSteerPointerInside); + pageChatInput.addEventListener('mousedown', keepSteerPointerInside); + pageChatInput.addEventListener('click', (e) => { + keepSteerPointerInside(e); + if (!steerLocked) focusPageChatInput('page-chat-input-click'); + }); + pageChatInput.addEventListener('input', () => { syncPageChatVisual(); }); @@ -6995,7 +8348,7 @@ void main() { syncPageChatFocusRing(); setTimeout(() => { if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (!pageChatInput.value.trim()) collapsePageChat(); scheduleSteerFocusRecover('steer-blur-recover'); }, 120); @@ -7039,7 +8392,7 @@ void main() { globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false'; globalBarBrandEl.setAttribute('aria-label', connected ? 'Impeccable live mode' - : 'Impeccable live mode: agent not polling'); + : 'Impeccable live mode - agent not polling'); globalBarBrandEl.removeAttribute('title'); globalBarBrandEl.style.cursor = connected ? 'default' : 'help'; const mark = globalBarBrandEl.querySelector('[data-brand-mark]'); @@ -7077,7 +8430,7 @@ void main() { }); agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip'; agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP; - document.body.appendChild(agentPollTooltipEl); + uiAppend(agentPollTooltipEl); return agentPollTooltipEl; } @@ -7131,7 +8484,7 @@ void main() { // Custom focus-visible for bar buttons. Browser default is a heavy // blue ring that looks jarring on the dark capsule. Replace with a // soft accent-tinted inner ring that respects the bar's palette. - if (!document.getElementById(PREFIX + '-bar-focus-style')) { + if (!uiGetById(PREFIX + '-bar-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-bar-focus-style'; s.textContent = @@ -7143,7 +8496,7 @@ void main() { '@keyframes impeccable-agent-dot { 0%, 100% { opacity: 0.45; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1); } }' + '#' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: impeccable-agent-dot 1.4s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: none; opacity: 0.9; } }'; - document.head.appendChild(s); + uiAppendStyle(s); } globalBarEl = el('div', { @@ -7176,7 +8529,7 @@ void main() { brand.id = PREFIX + '-global-bar-brand'; brand.dataset.agentConnected = 'false'; brand.setAttribute('role', 'img'); - brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling'); + brand.setAttribute('aria-label', 'Impeccable live mode - agent not polling'); const brandMark = el('span', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -7211,7 +8564,7 @@ void main() { inner.id = PREFIX + '-global-bar-inner'; globalBarEl.appendChild(inner); - // --- button factory: icon-only at rest, label slides in on hover/active --- + // Button factory: icon-only at rest, label slides in on hover/active. function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { const b = el('button', { position: 'relative', @@ -7506,6 +8859,7 @@ void main() { color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', }); + exitBtn.id = PREFIX + '-exit'; exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = 'oklch(58% 0.15 35)'; exitBtn.style.background = P.exitHover; }); @@ -7530,8 +8884,8 @@ void main() { try { window.focus(); } catch { /* in-app preview may block */ } }, true); - document.body.appendChild(pendingDockEl); - document.body.appendChild(globalBarEl); + uiAppend(pendingDockEl); + uiAppend(globalBarEl); defangOutsideHandlers(pendingDockEl); defangOutsideHandlers(globalBarEl); @@ -7553,11 +8907,11 @@ void main() { } function updateGlobalBarState() { - const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); - const detectBadge = document.getElementById(PREFIX + '-detect-badge'); - const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); - const insertToggle = document.getElementById(PREFIX + '-insert-toggle'); - const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const detectToggle = uiGetById(PREFIX + '-detect-toggle'); + const detectBadge = uiGetById(PREFIX + '-detect-badge'); + const pickToggle = uiGetById(PREFIX + '-pick-toggle'); + const insertToggle = uiGetById(PREFIX + '-insert-toggle'); + const designToggle = uiGetById(PREFIX + '-design-toggle'); const theme = globalBarEl?.dataset.theme || 'light'; const P = barPaletteForTheme(theme); @@ -7751,8 +9105,9 @@ void main() { pendingApplyInFlight = false; } if (globalBarEl) { - globalBarEl.style.transform = 'translateY(100%)'; - setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300); + globalBarEl.style.transition = 'none'; + globalBarEl.remove(); + globalBarEl = null; } pageChatEl = null; pageChatInput = null; @@ -7765,6 +9120,7 @@ void main() { if (barEl) { barEl.remove(); barEl = null; } if (pickerEl) { pickerEl.remove(); pickerEl = null; } if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; } + if (editBadgeProxyRoot) { editBadgeProxyRoot.remove(); editBadgeProxyRoot = null; editBadgeProxyByTarget = new Map(); } if (evtSource) { evtSource.close(); evtSource = null; } document.removeEventListener('mousemove', handleMouseMove, true); document.removeEventListener('click', handleClick, true); @@ -7777,9 +9133,9 @@ void main() { console.log('[impeccable] Live mode exited.'); } - // --------------------------------------------------------------------------- + // // Design System Panel - visualizes the project's .impeccable/design.json sidecar - // --------------------------------------------------------------------------- + // const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; const DESIGN_PANEL_WIDTH = 440; @@ -7847,7 +9203,7 @@ void main() { root.className = 'root'; designShadow.appendChild(root); - document.body.appendChild(designHost); + uiAppend(designHost); // The host is pointer-events: none; the panel inside the shadow DOM // manages its own auto/none. Events bubble through the shadow boundary, // so attaching here silences host-page outside-interaction handlers @@ -7889,7 +9245,7 @@ void main() { .root * { box-sizing: border-box; } button { font: inherit; color: inherit; } - /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + /* Panel shell: chrome matches the bar; body canvas stays neutral */ .panel { position: fixed; top: 12px; bottom: 72px; right: 12px; width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); @@ -7955,7 +9311,7 @@ void main() { .panel-body::-webkit-scrollbar { width: 8px; } .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } - /* --- States --- */ + /* States */ .empty, .loading, .error { margin: 16px 4px; padding: 28px 20px; text-align: center; @@ -7966,7 +9322,7 @@ void main() { .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; } .error { color: oklch(45% 0.15 25); } - /* --- Stale hint --- */ + /* Stale hint */ .stale { display: flex; align-items: center; gap: 8px; margin: 8px 4px 12px; @@ -7979,7 +9335,7 @@ void main() { .stale-text { flex: 1; min-width: 0; } .stale-text strong { color: ${DP.ink}; font-weight: 600; } - /* --- Parsed-md fallback banner --- */ + /* Parsed-md fallback banner */ .parsed-md-cta { margin: 8px 4px 14px; padding: 14px 16px; @@ -7991,7 +9347,7 @@ void main() { .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; } .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; } - /* --- Tile primitives --- */ + /* Tile primitives */ .tile { position: relative; background: ${DP.tile}; @@ -8010,7 +9366,7 @@ void main() { } .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; } - /* --- Color tile --- */ + /* Color tile */ .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; } .c-tile:hover { transform: translateY(-1px); } .c-hero { @@ -8025,7 +9381,7 @@ void main() { .c-ramp > span { flex: 1; } .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Type tile --- */ + /* Type tile */ .t-tile { } .t-specimen { margin: 4px 0 6px; @@ -8035,7 +9391,7 @@ void main() { .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; } .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Shadow tile --- */ + /* Shadow tile */ .s-tile { } .s-surface { height: 60px; margin: 8px 2px 10px; @@ -8045,14 +9401,14 @@ void main() { .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; } .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; } - /* --- Radii strip --- */ + /* Radii strip */ .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; } .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; } .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); } .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; } .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; } - /* --- Component tile (hosts live primitives) --- */ + /* Component tile (hosts live primitives) */ .cmp-tile { } .cmp-stage { margin: 12px -4px 0; @@ -8066,7 +9422,7 @@ void main() { .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; } .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; } - /* --- Collapsible --- */ + /* Collapsible */ .coll { margin: 0 4px 8px; background: ${DP.tile}; @@ -8131,7 +9487,7 @@ void main() { .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; } .coll .overview-body li { margin-bottom: 3px; } - /* --- raw tab markdown (unchanged layout, neutralized palette) --- */ + /* raw tab markdown (unchanged layout, neutralized palette) */ .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; } .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; } .md h1 { font-size: 18px; } @@ -8303,7 +9659,7 @@ void main() { return box; } - // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 --- + // Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 function renderDesignVisual(body, parsed, sidecar) { const frontmatter = parsed?.frontmatter || {}; @@ -8667,7 +10023,7 @@ void main() { return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components'); } - // --- Collapsibles --------------------------------------------------------- + // Collapsibles. function buildCollapsible(key, label, count) { const wrap = document.createElement('div'); @@ -8775,7 +10131,7 @@ void main() { return s.replace(/\s+#.*$/, '').trim(); } - // --- Raw tab: minimal markdown renderer (subset) -------------------------- + // Raw tab: minimal markdown renderer (subset) function renderRawTab(body, md) { const wrap = document.createElement('div'); @@ -8908,9 +10264,9 @@ void main() { } catch { /* ignore */ } } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // function init() { try { history.scrollRestoration = 'manual'; } catch {} diff --git a/.cursor/skills/impeccable/scripts/live-completion.mjs b/.cursor/skills/impeccable/scripts/live-completion.mjs index 86b637fff..986773066 100644 --- a/.cursor/skills/impeccable/scripts/live-completion.mjs +++ b/.cursor/skills/impeccable/scripts/live-completion.mjs @@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.mode === 'error') return 'error'; + if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error'; return 'agent_done'; } diff --git a/.cursor/skills/impeccable/scripts/live-inject.mjs b/.cursor/skills/impeccable/scripts/live-inject.mjs index b9d3df41d..3a1f36e46 100644 --- a/.cursor/skills/impeccable/scripts/live-inject.mjs +++ b/.cursor/skills/impeccable/scripts/live-inject.mjs @@ -17,11 +17,38 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './impeccable-paths.mjs'; +import { + applySvelteKitLiveAdapter, + detectSvelteKitProject, + removeSvelteKitLiveAdapter, +} from './live-sveltekit-adapter.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start'; +const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end'; + +export const LIVE_IGNORE_PATTERNS = Object.freeze([ + '.impeccable/hook.cache.json', + '.impeccable/live/server.json', + '.impeccable/live/sessions/', + '.impeccable/live/previews/', + '.impeccable/live/annotations/', + '.impeccable/live/cache/', + '.impeccable/live/manual-edit-apply-transaction.json', + '.impeccable/live/manual-edit-events.jsonl', + '.impeccable/live/manual-edit-evidence/', + '.impeccable/live/pending-manual-edits.json', + '.impeccable/live/deferred-svelte-component-accepts.json', + '.impeccable-live.json', + '.impeccable-live/', + 'node_modules/.impeccable-live/', + 'src/lib/impeccable/ImpeccableLiveRoot.svelte', + 'src/lib/impeccable/__runtime.js', + 'src/lib/impeccable/[0-9a-f]*/', +]); /** * Hard-excluded directory patterns. These are NEVER user-facing pages and @@ -83,8 +110,14 @@ Output (JSON): validateConfig(config); const resolvedFiles = resolveFiles(process.cwd(), config); + const svelteKit = detectSvelteKitProject(process.cwd(), config); if (args.includes('--remove')) { + if (svelteKit) { + const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config }); + console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; @@ -110,6 +143,13 @@ Output (JSON): console.error(JSON.stringify({ ok: false, error: 'missing_port' })); process.exit(1); } + const gitIgnore = ensureLiveGitIgnores(process.cwd()); + + if (svelteKit) { + const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config }); + console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); @@ -129,10 +169,68 @@ Output (JSON): }; }); const anyInserted = results.some((r) => r.inserted); - console.log(JSON.stringify({ ok: anyInserted, port, results })); + console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results })); if (!anyInserted) process.exit(1); } +export function ensureLiveGitIgnores(cwd = process.cwd()) { + const target = resolveIgnoreTarget(cwd); + const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : ''; + const block = [ + IGNORE_MARKER_OPEN, + ...LIVE_IGNORE_PATTERNS, + IGNORE_MARKER_CLOSE, + ].join('\n'); + const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`); + + let updated; + if (markerRe.test(existing)) { + updated = existing.replace(markerRe, block); + } else { + const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n'; + updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`; + } + + if (updated !== existing) { + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + fs.writeFileSync(target.path, updated, 'utf-8'); + } + + return { + file: path.relative(cwd, target.path).split(path.sep).join('/'), + mode: target.mode, + changed: updated !== existing, + patterns: [...LIVE_IGNORE_PATTERNS], + }; +} + +function resolveIgnoreTarget(cwd) { + const gitExcludePath = resolveGitInfoExcludePath(cwd); + if (gitExcludePath) { + return { path: gitExcludePath, mode: 'git-info-exclude' }; + } + return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' }; +} + +function resolveGitInfoExcludePath(cwd) { + const dotGit = path.join(cwd, '.git'); + if (!fs.existsSync(dotGit)) return null; + + const stat = fs.statSync(dotGit); + if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude'); + if (!stat.isFile()) return null; + + const body = fs.readFileSync(dotGit, 'utf-8').trim(); + const match = body.match(/^gitdir:\s*(.+)$/i); + if (!match) return null; + const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]); + return path.join(gitDir, 'info', 'exclude'); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Expand config.files (which may contain glob patterns) into a literal list * of existing file paths relative to rootDir. Literal entries pass through; diff --git a/.cursor/skills/impeccable/scripts/live-insert.mjs b/.cursor/skills/impeccable/scripts/live-insert.mjs index 09d4d55be..0658e9914 100644 --- a/.cursor/skills/impeccable/scripts/live-insert.mjs +++ b/.cursor/skills/impeccable/scripts/live-insert.mjs @@ -21,6 +21,11 @@ import { buildCssAuthoring, buildCssSelectorPrefixExamples, } from './live-wrap.mjs'; +import { + buildSvelteComponentCssAuthoring, + scaffoldSvelteComponentInsertSession, + shouldUseSvelteComponentInjection, +} from './live-svelte-component.mjs'; const INSERT_POSITIONS = new Set(['before', 'after']); @@ -192,6 +197,41 @@ Output (JSON): const styleMode = detectStyleMode(targetFile); const isJsx = commentSyntax.open === '{/*'; const spliceIndex = computeInsertLine(startLine, endLine, position); + const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); + + if (shouldUseSvelteComponentInjection(targetFile)) { + const session = scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile: relTargetFile, + insertLine: spliceIndex + 1, + position, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + anchorLines: lines.slice(startLine, endLine + 1), + cwd: process.cwd(), + }); + console.log(JSON.stringify({ + mode: 'insert', + position, + file: session.manifestFile, + sourceFile: relTargetFile, + previewMode: 'svelte-component', + componentDir: session.componentDir, + propContract: session.propContract, + insertLine: 1, + sourceInsertLine: spliceIndex + 1, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + commentSyntax, + styleMode: 'svelte-component', + styleTag: null, + cssSelectorPrefixExamples: [], + cssAuthoring: buildSvelteComponentCssAuthoring(count), + })); + return; + } + const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? ''; @@ -216,7 +256,7 @@ Output (JSON): console.log(JSON.stringify({ mode: 'insert', position, - file: path.relative(process.cwd(), targetFile), + file: relTargetFile, insertLine: insertLine + 1, commentSyntax, styleMode: styleMode.mode, diff --git a/.cursor/skills/impeccable/scripts/live-poll.mjs b/.cursor/skills/impeccable/scripts/live-poll.mjs index fad836612..1e1259fbc 100644 --- a/.cursor/skills/impeccable/scripts/live-poll.mjs +++ b/.cursor/skills/impeccable/scripts/live-poll.mjs @@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs'; // that ceiling and loop in `pollOnce` to synthesize a long poll without // depending on the standalone undici package. export const PER_REQUEST_TIMEOUT_MS = 270_000; +export const DEFAULT_EVENT_LEASE_MS = 600_000; const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); @@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) { ? totalDeadline - Date.now() : PER_REQUEST_TIMEOUT_MS; const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); - const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); + const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`); if (res.status === 401) { const err = new Error('Authentication failed. The server token may have changed.'); @@ -317,7 +318,7 @@ Modes: Options: --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) - --file PATH Attach a source file path to the reply (generate flow) + --file PATH Attach a source file path to the reply (generate/steer flow) --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --help Show this help message diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs index 16c8285b9..cd1091b88 100644 --- a/.cursor/skills/impeccable/scripts/live-server.mjs +++ b/.cursor/skills/impeccable/scripts/live-server.mjs @@ -42,6 +42,10 @@ import { } from './live-manual-edits-buffer.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs'; +import { + applyDeferredSvelteComponentAccepts, + removeAllSvelteComponentSessions, +} from './live-svelte-component.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated @@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; +const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2; const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); function tombstoneTimedOutApplyId(eventId, details = {}) { @@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) { return entry.event; } entry.leaseUntil = Date.now() + leaseMs; + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return entry.event; } @@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) { const acknowledged = state.pendingEvents[idx].event; state.pendingEvents.splice(idx, 1); scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return acknowledged; } +function findPendingEventById(id) { + if (!id) return null; + const entry = state.pendingEvents.find((item) => item.event?.id === id); + return entry?.event || null; +} + function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; return `live-poll.mjs --reply ${id} done --data ''`; @@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) { return summary; } +function summarizeActiveSessionForClient(snapshot = {}) { + return { + id: snapshot.id, + phase: snapshot.phase, + pageUrl: snapshot.pageUrl ?? null, + sourceFile: snapshot.sourceFile ?? null, + previewFile: snapshot.previewFile ?? null, + previewMode: snapshot.previewMode ?? null, + expectedVariants: snapshot.expectedVariants ?? 0, + arrivedVariants: snapshot.arrivedVariants ?? 0, + visibleVariant: snapshot.visibleVariant ?? null, + checkpointRevision: snapshot.checkpointRevision ?? 0, + paramValues: snapshot.paramValues || {}, + }; +} + +function activeSessionSummaries() { + if (!state.sessionStore) return []; + return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot)); +} + +function cancelQueuedAnonymousExitEvents() { + let removed = 0; + for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) { + const event = state.pendingEvents[i]?.event; + if (event?.type !== 'exit' || event.id) continue; + state.pendingEvents.splice(i, 1); + removed += 1; + } + if (removed > 0) { + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); + } + return removed; +} + function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { const canceledById = new Map(); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); @@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() { clearTimeout(state.leaseTimer); state.leaseTimer = null; } - if (state.pendingPolls.length === 0) return; const now = Date.now(); const nextLeaseUntil = state.pendingEvents .map((entry) => entry.leaseUntil || 0) @@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() { state.leaseTimer = setTimeout(() => { state.leaseTimer = null; flushPendingPolls(); - }, Math.max(0, nextLeaseUntil - now)); + broadcastAgentPollingIfChanged(); + }, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS)); } function flushPendingPolls() { @@ -1032,7 +1082,9 @@ function flushPendingPolls() { } function agentPollingConnected() { - return state.pendingPolls.length > 0; + const now = Date.now(); + return state.pendingPolls.length > 0 + || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now); } function broadcastAgentPollingIfChanged() { @@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/status') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } - const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; + const sessions = activeSessionSummaries(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', @@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/events' && req.method === 'GET') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + clearTimeout(state.exitTimer); + state.exitTimer = null; + cancelQueuedAnonymousExitEvents(); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { type: 'connected', hasProjectContext: hasProjectContext(), agentPolling: agentPollingConnected(), + activeSessions: activeSessionSummaries(), }) + '\n\n'); state.sseClients.add(res); - clearTimeout(state.exitTimer); // Keepalive: SSE comment every 30s prevents silent connection drops. const heartbeat = setInterval(() => { @@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } } + if (msg.type === 'exit') { + cleanupSvelteComponentSessionsBeforeExit(); + } if (msg.type !== 'checkpoint') { enqueueEvent(msg); } @@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) { }); } +function sessionFileMetadataFromPollReply(file) { + if (!file || typeof file !== 'string') return { file }; + const normalized = file.split(path.sep).join('/'); + const base = { file: normalized }; + if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base; + if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base; + + let full; + try { + full = path.resolve(process.cwd(), normalized); + const rel = path.relative(process.cwd(), full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base; + } catch { + return base; + } + + try { + const manifest = JSON.parse(fs.readFileSync(full, 'utf-8')); + if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base; + return { + file: String(manifest.sourceFile).split(path.sep).join('/'), + sourceFile: String(manifest.sourceFile).split(path.sep).join('/'), + previewFile: normalized, + previewMode: 'svelte-component', + }; + } catch { + return base; + } +} + function handlePollPost(req, res) { let body = ''; req.on('data', (c) => { body += c; }); @@ -1965,6 +2053,16 @@ function handlePollPost(req, res) { res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); return; } + const pendingEventBeforeAck = findPendingEventById(msg.id); + if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done' + && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'steer_done_requires_file_or_message', + hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.', + })); + return; + } const acknowledgedEvent = acknowledgePendingEvent(msg.id); let skipJournalReply = false; let existingSession = null; @@ -1987,6 +2085,7 @@ function handlePollPost(req, res) { })); return; } + const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); if (state.sessionStore && msg.id && !skipJournalReply) { try { const eventType = msg.type === 'steer_done' @@ -2001,7 +2100,10 @@ function handlePollPost(req, res) { state.sessionStore.appendEvent({ type: eventType, id: msg.id, - file: msg.file, + file: replyFileMeta.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, message: msg.message, sourceEventType: acknowledgedEvent?.type, carbonize: msg.data?.carbonize === true, @@ -2010,7 +2112,16 @@ function handlePollPost(req, res) { } flushPendingPolls(); // Forward the reply to the browser via SSE - broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + broadcast({ + type: msg.type || 'done', + id: msg.id, + message: msg.message, + file: msg.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, + data: msg.data, + }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); @@ -2023,6 +2134,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { + cleanupSvelteComponentSessionsBeforeExit(); removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; @@ -2037,6 +2149,25 @@ function shutdown() { process.exit(0); } +function cleanupSvelteComponentSessionsBeforeExit() { + try { + removeAllSvelteComponentSessions(process.cwd()); + } catch (err) { + console.warn('[impeccable] Svelte component session cleanup failed:', err.message); + } +} + +function applyLegacyDeferredAcceptsOnStartup() { + try { + const result = applyDeferredSvelteComponentAccepts(process.cwd()); + if (result.applied > 0 || result.failed > 0) { + console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result)); + } + } catch (err) { + console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({ cwd: process.cwd(), reason: 'manual_edit_server_start_recovered_abandoned_transaction', }); +applyLegacyDeferredAcceptsOnStartup(); restorePendingEventsFromStore(); pruneStaleManualApplyEvidence(process.cwd()); const portArg = args.find(a => a.startsWith('--port=')); diff --git a/.cursor/skills/impeccable/scripts/live-session-store.mjs b/.cursor/skills/impeccable/scripts/live-session-store.mjs index 7562e3d5b..5ec4d34d4 100644 --- a/.cursor/skills/impeccable/scripts/live-session-store.mjs +++ b/.cursor/skills/impeccable/scripts/live-session-store.mjs @@ -106,6 +106,8 @@ function baseSnapshot(id) { phase: 'new', pageUrl: null, sourceFile: null, + previewFile: null, + previewMode: null, expectedVariants: 0, arrivedVariants: 0, visibleVariant: null, @@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; - next.sourceFile = event.file ?? next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0); next.pendingEventSeq = null; next.pendingEvent = null; if (event.carbonize === true) { @@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { } break; case 'checkpoint': + if (COMPLETED_PHASES.has(next.phase)) { + next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null }); + break; + } if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { next.phase = event.phase ?? next.phase; next.checkpointRevision = event.revision ?? next.checkpointRevision; next.activeOwner = event.owner ?? next.activeOwner; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.visibleVariant = event.visibleVariant ?? next.visibleVariant; + next.sourceFile = event.sourceFile ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; if (event.paramValues) next.paramValues = { ...event.paramValues }; } else { next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); @@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'steer_done': next.phase = 'steer_done'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.message = event.message ?? next.message; next.pendingEventSeq = null; next.pendingEvent = null; break; @@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'complete': next.phase = 'completed'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; next.pendingEventSeq = null; next.pendingEvent = null; break; diff --git a/.cursor/skills/impeccable/scripts/live-svelte-component.mjs b/.cursor/skills/impeccable/scripts/live-svelte-component.mjs new file mode 100644 index 000000000..dc35dc0ce --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live-svelte-component.mjs @@ -0,0 +1,826 @@ +/** + * Svelte live-mode component injection helpers. + * + * Variants are real .svelte components under node_modules/.impeccable-live//. + * The browser mounts them via Svelte 5 mount(); accept inlines the chosen + * variant back into the route source with props mapped to original bindings. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; + +export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live'; +export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`; +export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json'; + +const MUSTACHE_RE = /\{([^{}]+)\}/g; + +export function shouldUseSvelteComponentInjection(filePath) { + if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false; + return path.extname(filePath).toLowerCase() === '.svelte'; +} + +export function componentSessionDir(id, cwd = process.cwd()) { + return path.join(cwd, SVELTE_COMPONENT_ROOT, id); +} + +export function manifestPathForSession(id, cwd = process.cwd()) { + return path.join(componentSessionDir(id, cwd), 'manifest.json'); +} + +export function ensureRuntimeHelper(cwd = process.cwd()) { + const file = path.join(cwd, SVELTE_RUNTIME_FILE); + if (fs.existsSync(file)) return file; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8'); + return file; +} + +/** + * Extract ordered unique mustache expressions from markup (not inside ). + */ +export function extractMustacheExpressions(text) { + const expressions = []; + const seen = new Set(); + const lines = String(text || '').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('\n` + : ''; + return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n\n`; +} + +function buildInsertVariantStub(variantNum) { + return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; +} + +export function scaffoldSvelteComponentSession({ + id, + count, + sourceFile, + sourceStartLine, + sourceEndLine, + originalLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const originalMarkup = originalLines.join('\n'); + const contract = buildPropContract(extractMustacheExpressions(originalMarkup)); + const originalWithProps = substituteExprsWithProps(originalMarkup, contract); + + const manifest = { + id, + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + sourceStartLine, + sourceEndLine, + count, + propContract: contract, + originalMarkup, + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: contract, + }; +} + +export function scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile, + insertLine, + position, + anchorStartLine, + anchorEndLine, + anchorLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const anchorMarkup = (anchorLines || []).join('\n'); + const manifest = { + id, + mode: 'insert', + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + insertLine, + position, + anchorStartLine, + anchorEndLine, + originalMarkup: anchorMarkup, + anchorMarkup, + count, + propContract: [], + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: [], + }; +} + +export function findSvelteComponentManifest(id, cwd = process.cwd()) { + const direct = manifestPathForSession(id, cwd); + if (fs.existsSync(direct)) { + return readManifest(direct); + } + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return null; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(root, entry.name, 'manifest.json'); + if (!fs.existsSync(candidate)) continue; + try { + const manifest = readManifest(candidate); + if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; + } catch { /* skip */ } + } + return null; +} + +export function readManifest(manifestPath) { + const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + return { + ...data, + manifestPath, + }; +} + +export function resolveSourceFile(sourceFile, cwd = process.cwd()) { + if (!sourceFile || path.isAbsolute(sourceFile)) { + throw new Error('Invalid svelte-component source file'); + } + const full = path.resolve(cwd, sourceFile); + const rel = path.relative(cwd, full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('Svelte-component source file escapes project root'); + } + if (!fs.existsSync(full)) { + throw new Error('Svelte-component source file not found: ' + sourceFile); + } + return full; +} + +function appendCssToSvelteStyle(lines, cssLines) { + const closeIdx = findLastStyleCloseLine(lines); + const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; + if (closeIdx === -1) { + return [...lines, '', '']; + } + return [ + ...lines.slice(0, closeIdx), + ...prepared, + ...lines.slice(closeIdx), + ]; +} + +function findLastStyleCloseLine(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + if (/<\/style\s*>/.test(lines[i])) return i; + } + return -1; +} + +function bakeParamValuesInCss(cssLines, paramValues) { + if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; + return cssLines.map((line) => { + let out = line; + for (const [key, value] of Object.entries(paramValues)) { + const varName = `--p-${key}`; + out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); + } + return out; + }); +} + +function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { + const css = String((cssLines || []).join('\n')); + if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; + + const rules = parseCssRules(css); + const output = []; + for (const rule of rules) { + appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); + } + return output.join('\n') + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ''); +} + +function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { + const prelude = rule.prelude.trim(); + const body = rule.body.trim(); + if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; + + if (/^@scope\b/i.test(prelude)) { + if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; + const inner = parseCssRules(body); + for (const innerRule of inner) { + const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); + if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; + output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); + } + return; + } + + const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); + if (!rewrittenPrelude) return; + output.push(formatCssRule(rewrittenPrelude, body)); +} + +function parseCssRules(css) { + const rules = []; + const text = String(css || ''); + let i = 0; + while (i < text.length) { + while (i < text.length && /\s/.test(text[i])) i++; + const preludeStart = i; + while (i < text.length && text[i] !== '{') i++; + if (i >= text.length) break; + const prelude = text.slice(preludeStart, i).trim(); + i++; + const bodyStart = i; + let depth = 1; + let quote = null; + let comment = false; + while (i < text.length && depth > 0) { + const ch = text[i]; + const next = text[i + 1]; + if (comment) { + if (ch === '*' && next === '/') { + comment = false; + i += 2; + continue; + } + i++; + continue; + } + if (quote) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === quote) quote = null; + i++; + continue; + } + if (ch === '/' && next === '*') { + comment = true; + i += 2; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); + if (prelude) rules.push({ prelude, body }); + } + return rules; +} + +function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { + const selectors = splitSelectorList(prelude); + const rewritten = []; + for (const selector of selectors) { + const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); + if (next) rewritten.push(next); + } + return rewritten.join(', '); +} + +function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { + let out = selector.trim(); + const hasVariant = /data-impeccable-variant/.test(out); + if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; + if (hasVariant) { + out = out.replace(variantSelectorRegex(variantNum), ''); + out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); + } + + const paramResult = rewriteParamSelectors(out, paramValues); + if (!paramResult.keep) return ''; + out = paramResult.selector; + + out = out + .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') + .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') + .replace(/\s+/g, ' ') + .trim(); + + out = out.replace(/^[>+~]\s*/, '').trim(); + if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; + return out; +} + +function rewriteParamSelectors(selector, paramValues) { + let keep = true; + const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { + if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; + const actual = paramValues[key]; + if (expected != null && String(actual) !== String(expected)) { + keep = false; + return ''; + } + if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { + keep = false; + return ''; + } + return ''; + }); + return { keep, selector: next }; +} + +function splitSelectorList(prelude) { + const selectors = []; + let start = 0; + let bracket = 0; + let paren = 0; + let quote = null; + for (let i = 0; i < prelude.length; i++) { + const ch = prelude[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '[') bracket++; + else if (ch === ']') bracket = Math.max(0, bracket - 1); + else if (ch === '(') paren++; + else if (ch === ')') paren = Math.max(0, paren - 1); + else if (ch === ',' && bracket === 0 && paren === 0) { + selectors.push(prelude.slice(start, i)); + start = i + 1; + } + } + selectors.push(prelude.slice(start)); + return selectors; +} + +function selectorHasVariant(selector, variantNum) { + return variantSelectorRegex(variantNum).test(selector); +} + +function variantSelectorRegex(variantNum) { + return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); +} + +function formatCssRule(selector, body) { + return `${selector} { ${body.trim()} }`; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { + const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); + const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); + const resultBase = { + file: manifest.sourceFile, + sourceFile: manifest.sourceFile, + previewMode: 'svelte-component', + componentDir: manifest.componentDir, + carbonize: false, + }; + if (!fs.existsSync(variantPath)) { + return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; + } + + const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); + if (manifest.mode === 'insert') { + return inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, + }); + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const contract = manifest.propContract || []; + const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); + const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract) + .split('\n') + .map((line) => line.trimEnd()); + + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const start = Number(manifest.sourceStartLine) - 1; + const end = Number(manifest.sourceEndLine) - 1; + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { + return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; + } + + const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, start), + ...indentedMarkup, + ...sourceLines.slice(end + 1), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, +}) { + if (!svelteMarkupHasVisibleContent(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; + } + if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const restoredMarkup = String(markup || '') + .split('\n') + .map((line) => line.trimEnd()); + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const insertIndex = Number(manifest.insertLine) - 1; + if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { + return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; + } + + const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; + const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, insertIndex), + ...indentedMarkup, + ...sourceLines.slice(insertIndex), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function svelteMarkupHasVisibleContent(markup) { + const text = String(markup || '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 0) return true; + return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); +} + +function mergeOriginalTopLevelAttrs(markup, originalMarkup) { + const variantOpen = matchOpeningTag(markup); + const originalOpen = matchOpeningTag(originalMarkup); + if (!variantOpen || !originalOpen) return markup; + if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; + + const variantAttrs = parseAttrSegments(variantOpen.attrs); + const originalAttrs = parseAttrSegments(originalOpen.attrs); + const additions = []; + let attrs = variantOpen.attrs; + + const originalClass = originalAttrs.get('class'); + const variantClass = variantAttrs.get('class'); + if (originalClass && variantClass) { + const merged = mergeStaticClassAttr(originalClass, variantClass); + if (merged) { + attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); + variantAttrs.set('class', { ...variantClass, raw: merged }); + } + } else if (originalClass && !variantClass) { + additions.push(originalClass.raw); + } + + for (const [name, attr] of originalAttrs) { + if (name === 'class') continue; + if (!variantAttrs.has(name)) additions.push(attr.raw); + } + + if (additions.length === 0 && attrs === variantOpen.attrs) return markup; + const nextOpen = variantOpen.prefix + + variantOpen.tag + + attrs + + additions.map((attr) => ' ' + attr.trim()).join('') + + variantOpen.close; + return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); +} + +function matchOpeningTag(markup) { + const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); + if (!match) return null; + return { + raw: match[0], + prefix: match[1], + tag: match[2], + attrs: match[3] || '', + close: match[4], + index: match.index || 0, + }; +} + +function parseAttrSegments(attrs) { + const out = new Map(); + const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; + let match; + while ((match = re.exec(attrs))) { + const raw = match[0]; + const name = match[1]; + out.set(name, { + name, + raw, + start: match.index, + end: match.index + raw.length, + }); + } + return out; +} + +function mergeStaticClassAttr(originalClass, variantClass) { + const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + if (!originalValue || !variantValue) return null; + const quote = variantValue[1]; + const classes = [ + ...variantValue[2].split(/\s+/), + ...originalValue[2].split(/\s+/), + ].filter(Boolean); + return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; +} + +export function removeSvelteComponentSession(id, cwd = process.cwd()) { + const dir = componentSessionDir(id, cwd); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* non-fatal */ } +} + +export function removeAllSvelteComponentSessions(cwd = process.cwd()) { + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('__')) continue; + try { + fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + } catch { /* non-fatal */ } + } +} + +export function deferredAcceptsPath(cwd = process.cwd()) { + const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16); + return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json'); +} + +export function readDeferredAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch { + return { accepts: [] }; + } +} + +export function writeDeferredAccept(entry, cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const data = readDeferredAccepts(cwd); + data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id); + data.accepts.push({ ...entry, createdAt: new Date().toISOString() }); + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + const data = readDeferredAccepts(cwd); + const pending = Array.isArray(data.accepts) ? data.accepts : []; + const results = []; + const remaining = []; + for (const entry of pending) { + try { + const manifest = findSvelteComponentManifest(entry.id, cwd); + if (!manifest) { + results.push({ id: entry.id, ok: false, error: 'manifest not found' }); + remaining.push(entry); + continue; + } + const result = inlineSvelteComponentAccept( + manifest, + entry.variantNum, + entry.paramValues || null, + cwd, + ); + results.push({ id: entry.id, ok: result.handled !== false, result }); + if (result.handled === false) remaining.push(entry); + } catch (err) { + results.push({ id: entry.id, ok: false, error: err.message }); + remaining.push(entry); + } + } + if (remaining.length > 0) { + fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8'); + } else { + try { fs.rmSync(file, { force: true }); } catch {} + } + return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results }; +} + +export function buildSvelteComponentCssAuthoring(count) { + const variantNumbers = Array.from({ length: count }, (_, i) => i + 1); + return { + mode: 'svelte-component', + styleTag: null, + strategy: 'component-style-block', + rulePattern: '.semantic-class { ... }', + selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'), + requirements: [ + 'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).', + 'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.', + 'Put variant CSS in the component ' : '')); + if (paramValues && Object.keys(paramValues).length > 0) { + lines.push( + bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close, + ); + } + lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + lines.push(bodyIndent + '
'); + lines.push(...bodyRestored); + lines.push(bodyIndent + '
'); + }; + + if (isJsx) { + const wrapperStyle = 'style={{ display: "contents" }}'; + lines.push(indent + '
'); + pushCarbonizeBody(indent + ' '); + lines.push(indent + '
'); + } else { + pushCarbonizeBody(indent); + } + + return lines; +} + +function reindentContent(contentLines, fromIndent, toIndent) { + return contentLines.map((line) => { + if (line.trim() === '') return ''; + if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length); + return toIndent + line.trimStart(); + }); +} + function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; @@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const needsCarbonize = !!(cssContent || hasHelperAttrs); - // Build the replacement const restored = deindentContent(variantContent, indent); - const replacement = []; - - if (cssContent) { - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); - // JSX targets need the CSS body wrapped in a template literal so that the - // `{` and `}` in CSS rules don't get parsed as JSX expressions. - replacement.push(indent + '' : '')); - if (paramValues && Object.keys(paramValues).length > 0) { - // Preserve the user's knob positions for the carbonize-cleanup agent - // to bake into the final CSS when it collapses scoped rules. - replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); - } - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); - } - - // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the - // carbonize CSS block working visually by re-wrapping the accepted content - // in a data-impeccable-variant="N" div with `display: contents` (so layout - // isn't affected). The carbonize agent strips this attribute + wrapper when - // it moves the CSS to a proper stylesheet. - // - // Style attribute syntax has to follow the host file's flavor — JSX files - // need the object form, otherwise React 19 throws "Failed to set indexed - // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. - if (cssContent) { - const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; - replacement.push(indent + '
'); - replacement.push(...restored); - replacement.push(indent + '
'); - } else { - replacement.push(...restored); - } + const replacement = buildCarbonizeReplacement({ + indent, + commentSyntax, + isJsx, + id, + variantNum, + cssContent, + paramValues, + restored, + }); const newLines = [ ...lines.slice(0, replaceRange.start), @@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; } +function readSourceShadowPreviewMeta(content, id) { + const escaped = escapeRegExp(id); + const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>'); + const match = String(content || '').match(wrapperRe); + if (!match) return null; + const tag = match[0]; + if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null; + const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file'); + const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start')); + const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end')); + if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null; + return { sourceFile, sourceStartLine, sourceEndLine }; +} + +function readHtmlAttr(tag, name) { + const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1')); + if (!match) return null; + return decodeHtmlAttr(match[2]); +} + +function decodeHtmlAttr(value) { + return String(value || '') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- @@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts }; diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index f609ac847..d7c580e2f 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -26,9 +26,9 @@ return; } - // --------------------------------------------------------------------------- + // // Design tokens - // --------------------------------------------------------------------------- + // // Brand kinpaku (gold) is pinned to the site's neo-kinpaku tokens // (see site/styles/kinpaku-tokens.css) so Accept / knobs / cycle-dots / @@ -115,19 +115,48 @@ { value: 'overdrive', label: 'Overdrive' }, ]; - // --------------------------------------------------------------------------- + const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions']; + const LIVE_UI_SURFACES = [ + { 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'] }, + { key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] }, + { key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-configure-input-wrap', PREFIX + '-input', PREFIX + '-configure-voice'] }, + { key: 'action-picker', ids: [PREFIX + '-picker'] }, + { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, + { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, + { key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] }, + { key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] }, + { key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] }, + { 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'] }, + { key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] }, + { key: 'design-system-panel', ids: [PREFIX + '-design-host'] }, + { key: 'toasts-and-errors', ids: [PREFIX + '-toast'] }, + { key: 'css-isolation-boundary', ids: [PREFIX + '-root'] }, + ]; + const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))]; + + // // State - // --------------------------------------------------------------------------- + // let state = 'IDLE'; let hoveredElement = null; let selectedElement = null; let currentSessionId = null; - let pendingAcceptedSession = null; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; + let svelteComponentSession = null; + let svelteRuntimePromise = null; + let pendingSvelteComponentRetryObserver = null; + let currentSourceFile = null; + let currentPreviewFile = null; + let currentPreviewMode = null; + let recoveryWaitingForAnchor = false; + let pendingAcceptedSession = null; let variantObserver = null; + let variantSelectionInFlight = false; + let variantSelectionPromise = null; + let recoveringEmptyCycling = false; let hasProjectContext = false; let selectedAction = 'impeccable'; let selectedCount = 3; @@ -175,14 +204,17 @@ let highlightEl = null; let tooltipEl = null; let barEl = null; + let barHideSeq = 0; let pickerEl = null; let toastEl = null; let scrollRaf = null; let editBadgeEl = null; + let editBadgeProxyRoot = null; + let editBadgeProxyByTarget = new Map(); - // --------------------------------------------------------------------------- + // // Helpers - // --------------------------------------------------------------------------- + // function own(el) { return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); @@ -204,8 +236,105 @@ return s; } + function rectIsUsableAnchor(rect) { + return !!rect && rect.width > 0.5 && rect.height > 0.5; + } + + function makeFrozenAnchor(el) { + if (!el || !el.getBoundingClientRect) return null; + const r = el.getBoundingClientRect(); + if (!rectIsUsableAnchor(r)) return null; + const rect = { + x: r.x, y: r.y, + top: r.top, left: r.left, + right: r.right, bottom: r.bottom, + width: r.width, height: r.height, + }; + return { + __impeccableFrozenAnchor: true, + tagName: el.tagName || 'DIV', + id: el.id || '', + classList: el.classList ? [...el.classList] : [], + hasAttribute: () => false, + getBoundingClientRect: () => rect, + }; + } + function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } + function cssId(id) { + if (window.CSS?.escape) return CSS.escape(id); + return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); + } + + function liveUiRoot() { + const root = window.__IMPECCABLE_LIVE_UI_ROOT__; + if (root && typeof root.appendChild === 'function') return root; + return document.body; + } + + function uiAppend(el) { + liveUiRoot().appendChild(el); + return el; + } + + function uiAppendStyle(styleEl) { + const root = liveUiRoot(); + if (root && root !== document.body) root.appendChild(styleEl); + else document.head.appendChild(styleEl); + return styleEl; + } + + function uiGetById(id) { + const root = liveUiRoot(); + if (root?.getElementById) { + const found = root.getElementById(id); + if (found) return found; + } + if (root?.querySelector) { + const found = root.querySelector('#' + cssId(id)); + if (found) return found; + } + return document.getElementById(id); + } + + function activeElementDeep() { + let active = document.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; + return active; + } + + window.__IMPECCABLE_LIVE_CHROME_CORE__ = { + version: 1, + adapter: window.__IMPECCABLE_LIVE_ADAPTER__ || 'dom', + mountContract: LIVE_CHROME_MOUNT_CONTRACT, + surfaces: LIVE_UI_SURFACES, + componentIds: LIVE_UI_COMPONENT_IDS, + root: liveUiRoot, + append: uiAppend, + appendStyle: uiAppendStyle, + getById: uiGetById, + activeElementDeep, + debugState: () => ({ + state, + currentSessionId, + expectedVariants, + arrivedVariants, + visibleVariant, + savedSession: loadSession(), + sourceFile: currentSourceFile, + previewFile: currentPreviewFile, + previewMode: currentPreviewMode, + barText: barEl?.textContent || null, + barConnected: !!barEl?.isConnected, + hasSvelteComponentSession: !!svelteComponentSession, + mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0, + pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver, + recoveryWaitingForAnchor, + evtSourceReadyState: evtSource ? evtSource.readyState : null, + }), + }; + // Modal-aware chrome: keep our floating UI clickable inside Radix / // Headless UI / vaul portals. // @@ -245,9 +374,9 @@ rootEl.addEventListener('focusin', stop); } - // --------------------------------------------------------------------------- + // // Highlight overlay - // --------------------------------------------------------------------------- + // function initHighlight() { highlightEl = document.createElement('div'); @@ -259,7 +388,7 @@ transition: HIGHLIGHT_TRANSITION, display: 'none', opacity: '0', }); - document.body.appendChild(highlightEl); + uiAppend(highlightEl); tooltipEl = document.createElement('div'); tooltipEl.id = PREFIX + '-tooltip'; @@ -273,7 +402,7 @@ letterSpacing: '0.02em', transition: TOOLTIP_TRANSITION, }); - document.body.appendChild(tooltipEl); + uiAppend(tooltipEl); } function showHighlight(el) { @@ -310,7 +439,7 @@ if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; } } - // --------------------------------------------------------------------------- + // // Annotation overlay (comment pins + kinpaku strokes) // // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned @@ -318,7 +447,7 @@ // drag) drops a comment pin; drag paints a kinpaku SVG stroke. All coords // are stored in element-local CSS px so they survive scroll / resize and // correlate directly with the captured PNG. - // --------------------------------------------------------------------------- + // const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it @@ -397,7 +526,7 @@ annotOverlayEl.addEventListener('pointermove', onAnnotMove); annotOverlayEl.addEventListener('pointerup', onAnnotUp); annotOverlayEl.addEventListener('pointercancel', onAnnotUp); - document.body.appendChild(annotOverlayEl); + uiAppend(annotOverlayEl); // Modal-host friendliness: pointer-events is already 'auto' on this // overlay; we only need to silence the host's outside-interaction // listeners. Don't override pointer-events here (the overlay toggles @@ -828,9 +957,9 @@ return wrap; } - // --------------------------------------------------------------------------- + // // Element context extraction - // --------------------------------------------------------------------------- + // function stripManualEditRuntimeState(root) { if (!root || root.nodeType !== 1) return; @@ -971,9 +1100,9 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } - // --------------------------------------------------------------------------- + // // The Bar - one floating element, three modes - // --------------------------------------------------------------------------- + // // Contextual-bar palette. Cached at init so every build*Row reads a // consistent set of colors; detectPageTheme runs once rather than on every @@ -1006,7 +1135,7 @@ padding: '6px', maxWidth: '520px', minWidth: '320px', }); - document.body.appendChild(barEl); + uiAppend(barEl); defangOutsideHandlers(barEl); } @@ -1041,6 +1170,8 @@ } function showBar(mode) { + barHideSeq += 1; + if (mode === 'cycling' && !ensureCyclingRenderable('show-bar')) return; barEl.innerHTML = ''; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); @@ -1058,11 +1189,12 @@ function hideBar() { if (!barEl) return; + const hideSeq = ++barHideSeq; stopVoice({ suppressSubmit: true }); if (configureKind === 'insert') clearInsertPicking(); barEl.style.opacity = '0'; barEl.style.transform = 'translateY(6px)'; - setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); + setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); if (state === 'EDITING') restoreInlineEditDrafts(); @@ -1071,6 +1203,7 @@ function updateBarContent(mode) { if (!barEl || barEl.style.display === 'none') return; + if (mode === 'cycling' && !ensureCyclingRenderable('update-bar')) return; barEl.innerHTML = ''; // Reset bar styling to the kinpaku picker palette barEl.style.background = BP.surface; @@ -1090,13 +1223,13 @@ syncPageChatFocus('update-bar-content'); } - // --- Configure row --- + // Configure row function syncConfigureInputChrome() { - const wrap = document.getElementById(PREFIX + '-configure-input-wrap'); - const input = document.getElementById(PREFIX + '-input'); + const wrap = uiGetById(PREFIX + '-configure-input-wrap'); + const input = uiGetById(PREFIX + '-input'); if (!wrap || !input) return; - const focused = document.activeElement === input; + const focused = activeElementDeep() === input; wrap.dataset.inputFocused = focused ? 'true' : 'false'; wrap.dataset.voiceListening = (voiceListening && voiceCtx?.mode === 'configure') ? 'true' : 'false'; wrap.style.borderColor = (voiceListening && voiceCtx?.mode === 'configure') @@ -1104,7 +1237,7 @@ : (focused ? BP.accentSoft : BP.hairline); } - // --- Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) --- + // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) function detectInsertAxisFromStyle(style) { const display = style?.display || 'block'; @@ -1377,7 +1510,7 @@ display: 'none', opacity: '0.9', }); - document.body.appendChild(insertLineEl); + uiAppend(insertLineEl); defangOutsideHandlers(insertLineEl); return insertLineEl; } @@ -1440,6 +1573,10 @@ /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { + if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor) return anchor; + } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (wrapper) { @@ -1557,6 +1694,11 @@ positionBar(); } + function showOrUpdateCyclingBar() { + if (barEl && barEl.style.display !== 'none') updateBarContent('cycling'); + else showBar('cycling'); + } + function buildPlaceholderResizeHandles() { if (!placeholderResizeLayerEl) return; placeholderResizeLayerEl.innerHTML = ''; @@ -1665,7 +1807,7 @@ } function isInsertCreateEnabled(btn) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); + btn = btn || uiGetById(PREFIX + '-insert-create'); return !!btn && btn.getAttribute('aria-disabled') !== 'true'; } @@ -1691,7 +1833,7 @@ lineHeight: '1.35', }); insertCreateTooltipEl.id = PREFIX + '-insert-create-tooltip'; - document.body.appendChild(insertCreateTooltipEl); + uiAppend(insertCreateTooltipEl); return insertCreateTooltipEl; } @@ -1723,8 +1865,8 @@ } function syncInsertCreateButton(btn, input) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); - input = input || document.getElementById(PREFIX + '-insert-input'); + btn = btn || uiGetById(PREFIX + '-insert-create'); + input = input || uiGetById(PREFIX + '-insert-input'); if (!btn || !input) return; const gate = insertCreateGateState(input); const ok = canCreateInsert(gate); @@ -1833,7 +1975,7 @@ voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!document.getElementById(PREFIX + '-configure-input-style')) { + if (!uiGetById(PREFIX + '-configure-input-style')) { const s = document.createElement('style'); s.id = PREFIX + '-configure-input-style'; s.textContent = @@ -1842,7 +1984,7 @@ '#' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: impeccable-configure-voice-pulse 1.1s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-configure-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } input.addEventListener('focus', () => syncConfigureInputChrome()); @@ -1949,6 +2091,9 @@ transition: 'border-color 0.15s ease', }); inputWrap.id = PREFIX + '-insert-input-wrap'; + inputWrap.addEventListener('pointerdown', (e) => e.stopPropagation()); + inputWrap.addEventListener('mousedown', (e) => e.stopPropagation()); + inputWrap.addEventListener('click', (e) => e.stopPropagation()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; @@ -1984,6 +2129,12 @@ voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; input.addEventListener('input', () => syncInsertCreateButton()); + input.addEventListener('pointerdown', (e) => e.stopPropagation()); + input.addEventListener('mousedown', (e) => e.stopPropagation()); + input.addEventListener('click', (e) => { + e.stopPropagation(); + try { input.focus({ preventScroll: true }); } catch { input.focus(); } + }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); @@ -2049,6 +2200,7 @@ }); create.addEventListener('mouseleave', hideInsertCreateTooltip); create.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); if (controlsLocked) { showManualApplyBusyToast(); return; } if (!isInsertCreateEnabled(create)) return; @@ -2060,7 +2212,7 @@ return row; } - // --- Generating row --- + // Generating row function buildGeneratingRow() { const row = el('div', { @@ -2086,19 +2238,24 @@ }); // Variants currently arrive atomically in a single file edit, so a // per-variant counter would lie. Say what's true. - status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + expectedVariants + ' variants...' - : 'Done'; + status.textContent = recoveryWaitingForAnchor + ? 'Variants ready. Reveal the selected element to resume.' + : (arrivedVariants < expectedVariants + ? 'Generating ' + expectedVariants + ' variants...' + : 'Done'); row.appendChild(status); return row; } - // --- Cycling row --- + // Cycling row const TUNE_ICON_SVG = ''; function buildCyclingRow() { + if (!ensureCyclingRenderable('build-cycling-row')) { + return el('div', { display: 'none' }); + } const row = el('div', { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 2px', @@ -2106,6 +2263,7 @@ // Prev const prev = navBtn('\u2190'); + prev.id = PREFIX + '-variant-prev'; prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); }); if (visibleVariant <= 1) prev.style.opacity = '0.3'; row.appendChild(prev); @@ -2118,11 +2276,13 @@ fontFamily: MONO, fontSize: '11px', fontWeight: '500', color: BP.textDim, minWidth: '24px', textAlign: 'center', }); + counter.id = PREFIX + '-variant-counter'; counter.textContent = visibleVariant + '/' + arrivedVariants; row.appendChild(counter); // Next const next = navBtn('\u2192'); + next.id = PREFIX + '-variant-next'; next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); }); if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3'; row.appendChild(next); @@ -2208,9 +2368,9 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders - // --- Saving row (waiting for agent to process accept/discard) --- + // Saving row (waiting for agent to process accept/discard) function buildSavingRow() { const row = el('div', { @@ -2235,7 +2395,7 @@ return row; } - // --- Confirmed row (green success, auto-dismisses) --- + // Confirmed row (green success, auto-dismisses) function buildConfirmedRow() { const row = el('div', { @@ -2256,7 +2416,7 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders function buildDots(clickable) { const container = el('div', { @@ -2290,10 +2450,7 @@ const idx = i; dot.addEventListener('click', (e) => { e.stopPropagation(); - visibleVariant = idx; - showVariantInDOM(currentSessionId, idx); - updateSelectedElement(); - updateBarContent('cycling'); + selectVariant(idx, 'variant_changed'); }); } container.appendChild(dot); @@ -2323,13 +2480,14 @@ function el(tag, styles) { const e = document.createElement(tag); + if (String(tag).toLowerCase() === 'button') e.type = 'button'; if (styles) Object.assign(e.style, styles); return e; } - // --------------------------------------------------------------------------- + // // Action picker popover - // --------------------------------------------------------------------------- + // function initActionPicker() { const P = barPaletteForTheme(detectPageTheme()); @@ -2384,16 +2542,20 @@ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent'; }); chip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + const prompt = uiGetById(PREFIX + '-input')?.value || ''; selectedAction = action.value; hideActionPicker(); updateBarContent('configure'); + const input = uiGetById(PREFIX + '-input'); + if (input && prompt) input.value = prompt; }); grid.appendChild(chip); }); pickerEl.appendChild(grid); - document.body.appendChild(pickerEl); + uiAppend(pickerEl); defangOutsideHandlers(pickerEl); // Cache the palette on the picker so toggleActionPicker's state refresh @@ -2433,7 +2595,33 @@ setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180); } - // --------------------------------------------------------------------------- + function ensureCyclingRenderable(reason) { + if (arrivedVariants > 0) { + if (visibleVariant < 1 || visibleVariant > arrivedVariants) visibleVariant = 1; + return true; + } + recoverEmptyCycling(reason); + return false; + } + + function recoverEmptyCycling(reason) { + if (recoveringEmptyCycling) return; + recoveringEmptyCycling = true; + try { + console.warn('[impeccable] Refusing to render empty variant cycling state:', reason); + const message = 'No variants were mounted. Please try again.'; + if (svelteComponentSession?.sessionId === currentSessionId) { + abortSvelteComponentInjection(currentSessionId, message); + return; + } + cleanup(); + showToast(message, 5000); + } finally { + recoveringEmptyCycling = false; + } + } + + // // Params panel (per-variant coarse controls) // // Variants may declare a parameter manifest via a JSON attribute on the @@ -2446,13 +2634,13 @@ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped // CSS can respond instantly without regeneration: // - // range / numeric toggle → CSS var (`--p-`) used via var(--p-foo, N) + // range / numeric toggle -> CSS custom property used by variant styles // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."] // // On variant switch, values reset to that variant's declared defaults. // On accept, current values are sent in the event payload so the agent // can bake them into the source-file write. - // --------------------------------------------------------------------------- + // let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide) let paramsPanelInner = null; // translating content (carries bg, padding, knobs) @@ -2507,7 +2695,7 @@ }); paramsPanelEl.appendChild(paramsPanelBody); - document.body.appendChild(paramsPanelEl); + uiAppend(paramsPanelEl); // Don't override pointer-events: the panel toggles between 'none' (closed, // click-through) and 'auto' (open) on its own. Just silence the host's // outside-interaction listeners while the panel is open. @@ -2516,14 +2704,40 @@ } + function getMountedSvelteComponentAnchor(session = svelteComponentSession) { + const el = session?.mountTargetEl?.firstElementChild || null; + if (!el || !document.body.contains(el)) return null; + return rectIsUsableAnchor(el.getBoundingClientRect()) ? el : null; + } + + function resolveSvelteComponentAnchor(session = svelteComponentSession) { + return getMountedSvelteComponentAnchor(session) + || session?.swapAnchor + || null; + } + function getVisibleVariantEl() { if (!currentSessionId) return null; + if (svelteComponentSession?.sessionId === currentSessionId) { + return resolveSvelteComponentAnchor() + || svelteComponentSession.wrapperEl + || null; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } function parseVariantParams(variantEl) { + // Svelte component variants can't carry a `data-impeccable-params` attribute: + // the compiler reads `{` inside attribute values as expression delimiters, so + // JSON-with-braces breaks the build. For that path the params live in a sidecar + // params.json keyed by variant number, loaded into the session at mount time. + if (svelteComponentSession?.sessionId === currentSessionId) { + const byVariant = svelteComponentSession.paramsByVariant || {}; + const params = byVariant[String(visibleVariant)] || byVariant[visibleVariant]; + return Array.isArray(params) ? params : []; + } if (!variantEl) return []; const raw = variantEl.getAttribute('data-impeccable-params'); if (!raw) return []; @@ -2685,11 +2899,11 @@ } } - // --------------------------------------------------------------------------- + // // Inline text editing - makes pure-text descendants of the picked element // directly contenteditable. Save stages copy edits in the live buffer; the // Apply copy edits dock later asks the AI to apply the staged batch. - // --------------------------------------------------------------------------- + // let inlineEditRows = []; let inlineEditDrafts = new Map(); @@ -2803,7 +3017,7 @@ function disableInlineEdit(opts = {}) { for (const row of inlineEditRows) { - if (document.activeElement === row.el) row.el.blur(); + if (activeElementDeep() === row.el) row.el.blur(); row.el.removeAttribute('contenteditable'); delete row.el.dataset.impeccableEditable; delete row.el.dataset.impeccableOriginalText; @@ -3133,7 +3347,7 @@ if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) { showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); } else { - showToast('Save failed: retry or cancel', 4000); + showToast('Save failed - retry or cancel', 4000); } } } @@ -3181,11 +3395,11 @@ } function ensureSpinKeyframes() { - if (document.getElementById(PREFIX + '-keyframes')) return; + if (uiGetById(PREFIX + '-keyframes')) return; const style = document.createElement('style'); style.id = PREFIX + '-keyframes'; style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }'; - document.head.appendChild(style); + uiAppendStyle(style); } function pendingApplyLabel(count) { @@ -3318,10 +3532,10 @@ closeTunePopover(); } if (barEl && barEl.style.display !== 'none' && state === 'CONFIGURING') { - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value : ''; updateBarContent('configure'); - const nextInput = document.getElementById(PREFIX + '-input'); + const nextInput = uiGetById(PREFIX + '-input'); if (nextInput) nextInput.value = prompt; } if (editBadgeEl && editBadgeEl.style.display !== 'none') { @@ -3455,19 +3669,19 @@ updatePendingCounter(remaining); if (result.failed && result.failed.length > 0) { console.warn('[impeccable] some copy edits failed:', result.failed); - showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed - see console', 5000); } else { const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0); if (n > 0) { showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); } else { console.warn('[impeccable] apply returned no verified edits:', result); - showToast('No edits applied, see console', 4000); + showToast('No edits applied - see console', 4000); } } } catch (err) { console.error('[impeccable] commit failed:', err); - showToast('Apply failed, see console', 4000); + showToast('Apply failed - see console', 4000); } finally { if (waitForSseCompletion) return; const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0; @@ -3497,7 +3711,7 @@ } } catch (err) { console.error('[impeccable] discard failed:', err); - showToast('Discard failed, see console', 4000); + showToast('Discard failed - see console', 4000); } } @@ -3645,7 +3859,7 @@ const failedCount = numberOrNull(msg.failedCount) || 0; const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0; if (failedCount > 0) { - showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000); + showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed - see console', 5000); } else if (appliedCount > 0) { showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500); } @@ -3799,9 +4013,164 @@ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); } - // --------------------------------------------------------------------------- + // // Edit content badge - floating button at element top-right to enter EDITING mode - // --------------------------------------------------------------------------- + // + + function usesShadowChromeRoot() { + const root = liveUiRoot(); + return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; + } + + function setImportantStyle(el, name, value) { + el.style.setProperty(name, value, 'important'); + } + + function initEditBadgeHitProxies() { + if (!usesShadowChromeRoot() || editBadgeProxyRoot) return; + editBadgeProxyRoot = document.createElement('div'); + editBadgeProxyRoot.id = PREFIX + '-edit-badge-hit-proxies'; + editBadgeProxyRoot.setAttribute('aria-hidden', 'true'); + const styles = { + all: 'initial', + position: 'fixed', + inset: '0', + width: '100vw', + height: '100vh', + zIndex: String(Z.toast + 1), + pointerEvents: 'none', + background: 'transparent', + overflow: 'visible', + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + document.body.appendChild(editBadgeProxyRoot); + } + + function styleEditBadgeProxy(proxy, target) { + const rect = target.getBoundingClientRect(); + const cursor = getComputedStyle(target).cursor || 'pointer'; + const styles = { + all: 'initial', + position: 'fixed', + left: rect.left + 'px', + top: rect.top + 'px', + width: rect.width + 'px', + height: rect.height + 'px', + margin: '0', + padding: '0', + border: '0', + borderRadius: '0', + background: 'transparent', + color: 'transparent', + opacity: '0.001', + pointerEvents: 'auto', + cursor, + zIndex: String(Z.toast + 2), + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(proxy, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + } + + function proxyMouseEvent(type, source, target) { + let event; + try { + event = new MouseEvent(type, { + bubbles: type !== 'mouseenter' && type !== 'mouseleave', + cancelable: true, + composed: true, + clientX: source.clientX, + clientY: source.clientY, + screenX: source.screenX, + screenY: source.screenY, + button: source.button || 0, + buttons: source.buttons || 0, + ctrlKey: source.ctrlKey, + metaKey: source.metaKey, + shiftKey: source.shiftKey, + altKey: source.altKey, + }); + target.dispatchEvent(event); + } catch {} + } + + function bindEditBadgeProxy(proxy, target) { + const stop = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + proxy.addEventListener('mouseenter', (event) => { + stop(event); + proxyMouseEvent('mouseenter', event, target); + proxyMouseEvent('mouseover', event, target); + }); + proxy.addEventListener('mouseleave', (event) => { + stop(event); + proxyMouseEvent('mouseleave', event, target); + proxyMouseEvent('mouseout', event, target); + }); + proxy.addEventListener('mousedown', (event) => { + stop(event); + target.focus?.({ preventScroll: true }); + proxyMouseEvent('mousedown', event, target); + }); + proxy.addEventListener('mouseup', (event) => { + stop(event); + proxyMouseEvent('mouseup', event, target); + }); + proxy.addEventListener('click', (event) => { + stop(event); + target.click(); + syncEditBadgeHitProxies(); + }); + } + + function editBadgeProxyTargets() { + if (!usesShadowChromeRoot() || !editBadgeEl || editBadgeEl.style.display === 'none') return []; + return [...editBadgeEl.querySelectorAll('button')].filter((target) => { + if (target.disabled) return false; + const rect = target.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return false; + const style = getComputedStyle(target); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); + } + + function syncEditBadgeHitProxies() { + if (!usesShadowChromeRoot()) { + if (editBadgeProxyRoot) editBadgeProxyRoot.remove(); + editBadgeProxyRoot = null; + editBadgeProxyByTarget = new Map(); + return; + } + initEditBadgeHitProxies(); + if (!editBadgeProxyRoot) return; + const targets = editBadgeProxyTargets(); + const active = new Set(targets); + for (const [target, proxy] of editBadgeProxyByTarget) { + if (!active.has(target) || !target.isConnected) { + proxy.remove(); + editBadgeProxyByTarget.delete(target); + } + } + for (const target of targets) { + let proxy = editBadgeProxyByTarget.get(target); + if (!proxy) { + proxy = document.createElement('button'); + proxy.type = 'button'; + proxy.tabIndex = -1; + proxy.dataset.impeccableEditBadgeProxy = 'true'; + proxy.setAttribute('aria-hidden', 'true'); + bindEditBadgeProxy(proxy, target); + editBadgeProxyRoot.appendChild(proxy); + editBadgeProxyByTarget.set(target, proxy); + } + proxy.title = target.title || target.textContent || 'Edit copy'; + styleEditBadgeProxy(proxy, target); + } + } function initEditBadge() { editBadgeEl = document.createElement('div'); @@ -3813,10 +4182,11 @@ display: 'none', userSelect: 'none', }); - document.body.appendChild(editBadgeEl); + uiAppend(editBadgeEl); + initEditBadgeHitProxies(); // Remove focus rings on edit badge buttons + contenteditable elements - if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + if (!uiGetById(PREFIX + '-edit-badge-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-edit-badge-focus-style'; s.textContent = @@ -3826,21 +4196,26 @@ '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; - document.head.appendChild(s); + uiAppendStyle(s); } } function positionEditBadge() { - if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') { + syncEditBadgeHitProxies(); + return; + } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { if (editBadgeEl) editBadgeEl.style.display = 'none'; + syncEditBadgeHitProxies(); return; } editBadgeEl.style.display = 'flex'; @@ -4047,7 +4422,7 @@ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN; } // Re-render the bar so the Tune chip picks up the active styling. - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } function closeTunePopover() { @@ -4055,13 +4430,13 @@ hideParamsPanel(); if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT; if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') { - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } } - // --------------------------------------------------------------------------- + // // Variant cycling in DOM - // --------------------------------------------------------------------------- + // function isVariantShown(el) { if (!el) return false; @@ -4081,9 +4456,42 @@ } } - function showVariantInDOM(sessionId, num) { + function scheduleCyclingBarSync(sessionId, variantNum) { + requestAnimationFrame(() => { + if (state !== 'CYCLING') return; + if (currentSessionId !== sessionId) return; + if (visibleVariant !== variantNum) return; + showOrUpdateCyclingBar(); + syncCyclingControls(); + positionBar(); + }); + } + + function syncCyclingControls() { + const shown = svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0 + ? svelteComponentSession.mountedVariant + : visibleVariant; + const counter = uiGetById(PREFIX + '-variant-counter'); + if (counter && arrivedVariants > 0) counter.textContent = shown + '/' + arrivedVariants; + const prev = uiGetById(PREFIX + '-variant-prev'); + const next = uiGetById(PREFIX + '-variant-next'); + if (prev) prev.style.opacity = shown <= 1 ? '0.3' : '1'; + if (next) next.style.opacity = shown >= arrivedVariants ? '0.3' : '1'; + if (currentSessionId && state === 'CYCLING') saveSession(); + } + + async function showVariantInDOM(sessionId, num) { + if (svelteComponentSession?.sessionId === sessionId) { + visibleVariant = num; + const mounted = await mountSvelteComponentVariant(num); + if (!mounted) return false; + updateSelectedElement(); + refreshParamsPanel(); + scheduleCyclingBarSync(sessionId, num); + return true; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); - if (!wrapper) return; + if (!wrapper) return false; for (const child of wrapper.children) { const v = child.dataset ? child.dataset.impeccableVariant : null; if (!v) continue; @@ -4093,6 +4501,378 @@ // CYCLING yet, the subsequent CYCLING transition triggers its own // refresh) and every cycle step. refreshParamsPanel(); + return true; + } + + function isSvelteComponentManifestPath(filePath) { + return String(filePath || '').endsWith('manifest.json'); + } + + function parseOriginalMarkupElement(originalMarkup) { + const parser = new DOMParser(); + const doc = parser.parseFromString('
' + originalMarkup + '
', 'text/html'); + return doc.getElementById('impeccable-anchor')?.firstElementChild || null; + } + + function findLiveElementForOriginalMarkup(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const tag = origContent.tagName.toLowerCase(); + const cls = origContent.className; + let liveEl = null; + if (origContent.id) { + liveEl = document.getElementById(origContent.id); + } else if (cls) { + const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + for (const c of candidates) { + if (c.className === cls && !own(c)) { liveEl = c; break; } + } + if (!liveEl) { + const expectedClasses = String(cls).split(/\s+/).filter(Boolean); + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } + } + } + return liveEl; + } + + function isSvelteInsertManifest(manifest) { + return manifest?.previewMode === 'svelte-component' && manifest?.mode === 'insert'; + } + + function findLiveElementForSvelteManifest(manifest) { + if (isSvelteInsertManifest(manifest)) { + const anchor = findInsertAnchorInDom(); + if (anchor?.parentElement) return anchor; + } + return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function loadSvelteRuntime(runtimeModule) { + const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js'; + const url = new URL(modulePath, location.origin).href; + if (!svelteRuntimePromise) { + svelteRuntimePromise = import(/* @vite-ignore */ url); + } + return svelteRuntimePromise; + } + + // Svelte component variants declare their params in a sidecar params.json under + // componentDir (keyed by variant number), because a `data-impeccable-params` + // attribute with JSON braces can't survive the Svelte compiler. Returns a map of + // { "1": [...params], "2": [...] }; an empty object when the agent declared none. + async function loadSvelteComponentParams(manifest) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir) return {}; + const paramsPath = dir + '/params.json'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(paramsPath); + try { + const res = await fetch(url); + if (!res.ok) return {}; + const parsed = JSON.parse(await res.text()); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out = {}; + for (const [key, value] of Object.entries(parsed)) { + if (Array.isArray(value)) out[String(key)] = value; + } + return out; + } catch { + return {}; + } + } + + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { + const contract = manifest?.propContract || []; + const values = {}; + if (!liveEl || contract.length === 0) return values; + const sourceOriginal = parseOriginalMarkupElement(manifest.originalMarkup || ''); + if (!sourceOriginal) return values; + const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl); + for (const entry of contract) { + const token = '{' + entry.expr + '}'; + values[entry.prop] = map.get(token) || ''; + } + return values; + } + + async function mountSvelteComponentVariant(variantNum) { + if (!svelteComponentSession || !variantNum) return false; + const { manifest, mountTargetEl, sessionId } = svelteComponentSession; + try { + const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement; + svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null; + const runtime = await loadSvelteRuntime(manifest.runtimeModule); + const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte'; + const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now(); + const mod = await import(/* @vite-ignore */ moduleUrl); + const Component = mod.default; + if (svelteComponentSession.mountedInstance && runtime.unmount) { + await runtime.unmount(svelteComponentSession.mountedInstance); + svelteComponentSession.mountedInstance = null; + } + svelteComponentSession.mountedInstance = runtime.mount(Component, { + target: mountTargetEl, + props: { ...svelteComponentSession.propValues }, + intro: false, + }); + svelteComponentSession.mountedVariant = variantNum; + svelteComponentSession.runtime = runtime; + if (state === 'CYCLING') syncCyclingControls(); + const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (nextAnchor) { + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(nextAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = nextAnchor; + } else { + requestAnimationFrame(() => { + if (svelteComponentSession?.sessionId !== sessionId) return; + const settledAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!settledAnchor) return; + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(settledAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = settledAnchor; + }); + } + return true; + } catch (err) { + if (svelteComponentSession?.sessionId === sessionId) { + svelteComponentSession.swapAnchor = null; + } + console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err); + return false; + } + } + + function teardownSvelteComponentSession(restoreOriginal) { + if (!svelteComponentSession) return; + const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + if (restoreOriginal && detachedOriginal && wrapperEl?.parentElement) { + wrapperEl.parentElement.replaceChild(detachedOriginal, wrapperEl); + } else if (wrapperEl?.parentElement) { + wrapperEl.remove(); + } + svelteComponentSession = null; + svelteRuntimePromise = null; + } + + function applyOriginalAttrsToSvelteAnchor(el, originalMarkup) { + if (!el || !originalMarkup) return; + const original = parseOriginalMarkupElement(originalMarkup); + if (!original || original.tagName !== el.tagName) return; + for (const attr of original.attributes) { + if (attr.name === 'class') { + for (const className of attr.value.split(/\s+/).filter(Boolean)) { + el.classList.add(className); + } + } else if (!el.hasAttribute(attr.name)) { + el.setAttribute(attr.name, attr.value); + } + } + } + + function commitAcceptedSvelteComponentToDom(sessionId) { + if (!svelteComponentSession || svelteComponentSession.sessionId !== sessionId) return false; + const { wrapperEl, runtime, mountedInstance, manifest } = svelteComponentSession; + const anchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!anchor || !wrapperEl?.parentElement) return false; + const committed = anchor.cloneNode(true); + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(committed, manifest.originalMarkup || ''); + } + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + wrapperEl.parentElement.replaceChild(committed, wrapperEl); + svelteComponentSession = null; + svelteRuntimePromise = null; + selectedElement = committed; + return true; + } + + async function injectSvelteComponentsFromManifest(manifestPath, sessionId) { + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(manifestPath); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + const manifest = JSON.parse(await res.text()); + if (manifest.id !== sessionId) return; + + const paramsByVariant = await loadSvelteComponentParams(manifest); + currentSessionId = sessionId; + expectedVariants = Number(manifest.count) || expectedVariants || 1; + rememberSessionFileMeta({ + sourceFile: manifest.sourceFile, + previewFile: manifestPath, + previewMode: 'svelte-component', + }); + if (state !== 'CYCLING') state = 'GENERATING'; + + const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { + recoveryWaitingForAnchor = false; + svelteComponentSession.paramsByVariant = paramsByVariant; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; + await mountSvelteComponentVariant(visibleVariant || 1); + state = 'CYCLING'; + showOrUpdateCyclingBar(); + saveSession(); + return; + } + + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) { + console.warn('[impeccable] Could not find original element in live DOM.'); + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants + ? visibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + saveSession(); + queueCheckpoint('svelte_component_anchor_missing'); + waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return; + } + + const wrapper = document.createElement('div'); + wrapper.dataset.impeccableVariants = sessionId; + wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1); + wrapper.dataset.impeccablePreview = 'svelte-component'; + wrapper.style.display = 'contents'; + + const mountTarget = document.createElement('div'); + mountTarget.dataset.impeccableComponentMount = sessionId; + mountTarget.style.display = 'contents'; + wrapper.appendChild(mountTarget); + + const insertMode = isSvelteInsertManifest(manifest); + const detachedOriginal = insertMode ? null : liveEl; + if (insertMode) { + removeInsertPlaceholderDom(); + if (manifest.position === 'before') liveEl.parentElement.insertBefore(wrapper, liveEl); + else liveEl.parentElement.insertBefore(wrapper, liveEl.nextSibling); + } else { + liveEl.parentElement.replaceChild(wrapper, liveEl); + } + + svelteComponentSession = { + sessionId, + manifest, + insertMode, + wrapperEl: wrapper, + mountTargetEl: mountTarget, + detachedOriginal, + mountedInstance: null, + mountedVariant: 0, + runtime: null, + propValues: buildSveltePropValuesFromLiveElement(detachedOriginal, manifest), + paramsByVariant, + }; + if (pendingSvelteComponentRetryObserver) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + } + recoveryWaitingForAnchor = false; + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + + const mounted = await mountSvelteComponentVariant(visibleVariant); + if (!mounted) { + // The compiled component threw (e.g. a Svelte compile error in the + // variant file). Don't strand the bar in an empty CYCLING state; restore + // the original element and reset to PICKING so the user can retry. + abortSvelteComponentInjection(sessionId, 'A variant failed to compile. Fix the component and re-run.'); + return; + } + + selectedElement = mountTarget.firstElementChild || mountTarget; + state = 'CYCLING'; + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.'); + } catch (err) { + console.error('[impeccable] Failed to mount Svelte component variants:', err); + abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.'); + } + } + + function waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }) { + if (pendingSvelteComponentRetryObserver) pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = new MutationObserver(() => { + if (svelteComponentSession?.sessionId === sessionId) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + return; + } + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) return; + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + injectSvelteComponentsFromManifest(manifestPath, sessionId); + }); + pendingSvelteComponentRetryObserver.observe(document.body, { childList: true, subtree: true }); + } + + // Reset cleanly when a Svelte component session can't mount: tear the wrapper + // down (restoring the original element), clear persisted session state, and + // return the bar to PICKING. Avoids the stuck 0/0 CYCLING bar. + function abortSvelteComponentInjection(sessionId, message) { + try { + if (svelteComponentSession?.sessionId === sessionId) { + teardownSvelteComponentSession(true); + } else { + const orphan = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (orphan) orphan.remove(); + } + } catch (err) { + console.warn('[impeccable] Svelte component abort cleanup failed:', err); + } + hideShaderOverlay(); + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + stopScrollLock(); + clearSession(); + clearHandled(); + resetSessionFileMeta(); + currentSessionId = null; + expectedVariants = 0; + arrivedVariants = 0; + visibleVariant = 0; + selectedElement = null; + state = 'PICKING'; + hideBar(); + if (message) showToast(message, 5000); } /** @@ -4101,6 +4881,11 @@ * This works even when the dev server caches HTML (Bun, static servers). */ function injectVariantsFromSource(filePath, sessionId) { + if (isSvelteComponentManifestPath(filePath)) { + injectSvelteComponentsFromManifest(filePath, sessionId); + return; + } + rememberSessionFileMeta({ file: filePath }); const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) @@ -4119,7 +4904,7 @@ const doc = parser.parseFromString(block, 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { - console.error('[impeccable] Variant wrapper not found in source file.'); + console.warn('[impeccable] Variant wrapper not found in source file.'); return; } @@ -4134,31 +4919,31 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; - if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - } - + const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); if (!liveEl) { - console.error('[impeccable] Could not find original element in live DOM.'); + console.warn('[impeccable] Could not find original element in live DOM.'); + selectedElement = document.body; + recoveryWaitingForAnchor = true; + state = 'GENERATING'; + showBar('generating'); + saveSession(); + showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } + recoveryWaitingForAnchor = false; // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); arrivedVariants = variants.length; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + recoverEmptyCycling('source-fallback-empty'); + return; + } const saved = loadSession(); const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants @@ -4170,8 +4955,9 @@ selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4184,21 +4970,129 @@ }); } - function cycleVariant(dir) { + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { + const map = new Map(); + if (!sourceOriginal || !liveOriginal) return map; + + const sourceNodes = collectTextNodes(sourceOriginal) + .filter((node) => /\{[^{}]+\}/.test(node.nodeValue || '')); + const liveTexts = collectTextNodes(liveOriginal) + .map((node) => normalizePreviewText(node.nodeValue || '')) + .filter(Boolean); + let liveIndex = 0; + + for (const sourceNode of sourceNodes) { + const sourceText = sourceNode.nodeValue || ''; + const tokens = sourceText.match(/\{[^{}]+\}/g) || []; + if (tokens.length === 0) continue; + + const liveText = liveTexts[liveIndex++] || ''; + if (!liveText) continue; + + if (tokens.length === 1) { + const token = tokens[0]; + const normalizedSource = normalizePreviewText(sourceText); + if (normalizedSource === token) { + map.set(token, liveText); + continue; + } + + const match = liveText.match(expressionTextMatcher(sourceText, [token])); + if (match && match[1]) map.set(token, match[1].trim()); + continue; + } + + if (normalizePreviewText(sourceText) === tokens.join(' ')) { + for (const token of tokens) { + const tokenLiveText = liveTexts[liveIndex - 1] || ''; + if (tokenLiveText) map.set(token, tokenLiveText); + } + } + } + + return map; + } + + function expressionTextMatcher(sourceText, tokens) { + let pattern = '^'; + let cursor = 0; + for (const token of tokens) { + const index = sourceText.indexOf(token, cursor); + if (index === -1) continue; + pattern += escapeRegExp(sourceText.slice(cursor, index)).replace(/\s+/g, '\\s*'); + pattern += '(.*?)'; + cursor = index + token.length; + } + pattern += escapeRegExp(sourceText.slice(cursor)).replace(/\s+/g, '\\s*') + '$'; + return new RegExp(pattern); + } + + function collectTextNodes(root) { + if (!root) return []; + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + nodes.push(node); + node = walker.nextNode(); + } + return nodes; + } + + function normalizePreviewText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + async function selectVariant(next, checkpointReason) { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - const next = visibleVariant + dir; + if (variantSelectionInFlight) return; if (next < 1 || next > arrivedVariants) return; - visibleVariant = next; - showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself - updateSelectedElement(); - updateBarContent('cycling'); - positionBar(); - saveSession(); - queueCheckpoint('variant_changed'); + if (next === visibleVariant) return; + + const previous = visibleVariant; + variantSelectionInFlight = true; + const selectionPromise = (async () => { + visibleVariant = next; + showOrUpdateCyclingBar(); + saveSession(); + const shown = await showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself + if (!shown) { + visibleVariant = previous; + await showVariantInDOM(currentSessionId, previous); + showOrUpdateCyclingBar(); + saveSession(); + return; + } + updateSelectedElement(); + showOrUpdateCyclingBar(); + positionBar(); + saveSession(); + if (checkpointReason) queueCheckpoint(checkpointReason); + })(); + variantSelectionPromise = selectionPromise; + try { + await selectionPromise; + } finally { + if (variantSelectionPromise === selectionPromise) variantSelectionPromise = null; + variantSelectionInFlight = false; + } + } + + function cycleVariant(dir) { + selectVariant(visibleVariant + dir, 'variant_changed'); } function updateSelectedElement() { if (!currentSessionId) return; + if (svelteComponentSession?.sessionId === currentSessionId) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; + return; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); @@ -4206,6 +5100,9 @@ } function readVisibleVariantFromDOM(sessionId) { + if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { + return svelteComponentSession.mountedVariant; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -4341,9 +5238,9 @@ // scrollY that the next resume needs to read. } - // --------------------------------------------------------------------------- + // // MutationObserver for progressive variant reveal - // --------------------------------------------------------------------------- + // function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -4426,10 +5323,11 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); updateSelectedElement(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4445,9 +5343,9 @@ return obs; } - // --------------------------------------------------------------------------- + // // Bar scroll tracking - // --------------------------------------------------------------------------- + // function startScrollTracking() { function tick() { @@ -4483,10 +5381,10 @@ if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; } } - // --------------------------------------------------------------------------- + // // SSE (server→browser) + fetch POST (browser→server) // Zero-dependency replacement for WebSocket. - // --------------------------------------------------------------------------- + // let evtSource = null; let sseRetries = 0; @@ -4509,6 +5407,7 @@ console.log('[impeccable] Live mode connected.'); syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); + restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; syncPageChatFocus('sse-connected'); break; @@ -4531,11 +5430,12 @@ break; case 'done': if (maybeCompleteSteer(msg)) break; + rememberSessionFileMeta(msg); // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); } @@ -4557,7 +5457,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; if (state !== 'GENERATING') return; showToast( - "Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.", + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", 15000, ); }, 2000); @@ -4571,12 +5471,18 @@ // the final complete event. Keep the browser in its recoverable // saving state while the source cleanup is still in flight. break; + case 'discarded': + if (msg.id && msg.id === currentSessionId) { + markSessionHandled(); + cleanup(); + } + break; case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; state = 'CYCLING'; updateBarContent('cycling'); - showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000); + showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; } if (maybeCompleteSteer(msg)) break; @@ -4656,6 +5562,9 @@ expectedVariants, arrivedVariants, visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, paramValues: { ...paramsCurrentValues }, }; } @@ -4665,6 +5574,20 @@ return sendEvent(checkpointPayload(reason)).catch(() => null); } + function sendSteerCheckpoint(id, reason, extra) { + if (!id) return Promise.resolve(null); + return sendEvent({ + type: 'checkpoint', + id, + revision: sessionState.nextCheckpointRevision(), + owner: browserOwner, + phase: 'steer', + reason, + pageUrl: location.pathname, + ...(extra || {}), + }).catch(() => null); + } + function queueCheckpoint(reason) { if (!currentSessionId) return; if (checkpointTimer) clearTimeout(checkpointTimer); @@ -4674,9 +5597,9 @@ }, 120); } - // --------------------------------------------------------------------------- + // // Event handlers - // --------------------------------------------------------------------------- + // function handleMouseMove(e) { if (pendingApplyInFlight) return; @@ -4860,7 +5783,7 @@ // // DISABLED: quick-Go workflows pay an extra harness round trip because // prefetch + generate arrive as two events instead of one. Re-enable with - // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to + // a browser-side debounce (~800-1000ms, cancelled on Go) if we want to // resurrect this. Server validator and skill dispatch remain in place so // flipping this flag is the only change needed. const PREFETCH_ENABLED = false; @@ -4876,6 +5799,14 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + const deepActive = activeElementDeep(); + if ( + deepActive + && own(deepActive) + && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + ) { + return; + } // While a contenteditable text-leaf is focused, let the browser handle // all keys except Escape. Escape cancels the current edit (restores // original text) and blurs without saving, staying in CONFIGURING. @@ -4982,7 +5913,7 @@ if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!selectedElement || state !== 'CONFIGURING') return; stopVoice({ suppressSubmit: true }); - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; // Commit any pending pin edit BEFORE we snapshot annotations. @@ -4996,6 +5927,7 @@ expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); // Flip to GENERATING immediately so the bar morphs without waiting on // capture + upload. The event is emitted from captureAndEmit() once the @@ -5054,7 +5986,7 @@ function handleInsertCreate() { if (!placeholderElement || !insertAnchorElement || state !== 'CONFIGURING' || configureKind !== 'insert') return; - const input = document.getElementById(PREFIX + '-insert-input'); + const input = uiGetById(PREFIX + '-insert-input'); const prompt = input ? input.value.trim() : ''; if (annotEditing) finalizeEditingPin(); const snapshot = { @@ -5064,10 +5996,12 @@ if (!canCreateInsert({ prompt, comments: snapshot.comments, strokes: snapshot.strokes })) return; stopVoice({ suppressSubmit: true }); + pendingAcceptedSession = null; currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); selectedElement = placeholderElement; insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement); @@ -5107,9 +6041,9 @@ captureAndEmit(elForCapture, basePayload, snapshot, captureRect); } - // --------------------------------------------------------------------------- + // // Screenshot capture + upload - // --------------------------------------------------------------------------- + // let msLoadPromise = null; function loadModernScreenshot() { @@ -5120,7 +6054,7 @@ s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; s.onload = () => resolve(window.modernScreenshot); s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; - document.head.appendChild(s); + uiAppendStyle(s); }); return msLoadPromise; } @@ -5235,11 +6169,113 @@ return '#ffffff'; } + function captureChromeNodes() { + const nodes = []; + const add = (node) => { + if (!node || node === document.body || nodes.includes(node)) return; + nodes.push(node); + }; + add(document.getElementById(PREFIX + '-root')); + [ + PREFIX + '-highlight', + PREFIX + '-tooltip', + PREFIX + '-bar', + PREFIX + '-picker', + PREFIX + '-params-panel', + PREFIX + '-insert-line', + PREFIX + '-insert-placeholder', + PREFIX + '-insert-create-tooltip', + PREFIX + '-annot', + PREFIX + '-design-host', + PREFIX + '-toast', + PREFIX + '-shader', + ].forEach((id) => add(uiGetById(id))); + return nodes; + } + + async function hideCaptureChromeForShaderProxy(fn) { + const saved = captureChromeNodes().map((node) => ({ + node, + visibility: node.style.visibility, + priority: node.style.getPropertyPriority('visibility'), + })); + for (const { node } of saved) { + node.style.setProperty('visibility', 'hidden', 'important'); + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + try { + return await fn(); + } finally { + for (const { node, visibility, priority } of saved) { + node.style.setProperty('visibility', visibility, priority); + } + } + } + + function shouldUseAncestorCropShaderProxy(el) { + // TODO: Enable this proxy for React/Vue/etc. adapters once their live + // preview mounts are covered by the same shader regression checks. + const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase(); + if (adapter === 'svelte' || adapter === 'sveltekit') return true; + if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true; + const wrapper = el?.closest?.('[data-impeccable-variants]'); + return wrapper?.dataset?.impeccablePreview === 'svelte-component'; + } + + function paintsShaderProxySurface(node) { + const s = getComputedStyle(node); + return !isTransparentColor(s.backgroundColor) + || (s.backgroundImage && s.backgroundImage !== 'none') + || paintsBackdrop(node); + } + + function findShaderProxyCaptureRoot(el) { + const doc = el.ownerDocument || document; + const er = el.getBoundingClientRect(); + let node = el.parentElement; + while (node && node !== doc.documentElement) { + const nr = node.getBoundingClientRect(); + const containsElement = + nr.width > 0 && nr.height > 0 && + nr.left <= er.left + 0.5 && + nr.top <= er.top + 0.5 && + nr.right >= er.right - 0.5 && + nr.bottom >= er.bottom - 0.5; + if (containsElement && paintsShaderProxySurface(node)) return node; + node = node.parentElement; + } + return null; + } + // Capture the element (with current annotations baked in) and return // { blob, paper }: the PNG Blob, plus the representative backdrop tone for the // shader's halftone ground (so capture, upload, and shader all agree on what // sits behind the element). Shared between the Go flow (uploads the blob) and // the shader-resume path. + async function captureElementFromRenderedAncestor(ms, el, opts) { + const doc = el.ownerDocument || document; + const captureRoot = findShaderProxyCaptureRoot(el); + if (!captureRoot) throw new Error('No painted ancestor for Svelte shader proxy'); + const rootCanvas = await ms.domToCanvas(captureRoot, opts); + const S = opts.scale; + const er = el.getBoundingClientRect(); + const rr = captureRoot.getBoundingClientRect(); + const sx = (er.left - rr.left) * S; + const sy = (er.top - rr.top) * S; + const sw = er.width * S; + const sh = er.height * S; + if (sw <= 0 || sh <= 0) throw new Error('Selected element has no visible capture rect'); + const crop = doc.createElement('canvas'); + crop.width = Math.max(1, Math.round(sw)); + crop.height = Math.max(1, Math.round(sh)); + const cctx = crop.getContext('2d', { willReadFrequently: true }); + cctx.drawImage(rootCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height); + const paper = dominantRgb01(cctx, crop.width, crop.height) || averageRgb01(cctx, crop.width, crop.height); + const blob = await new Promise((res) => crop.toBlob(res, 'image/png')); + if (!blob) throw new Error('Ancestor crop failed to produce a PNG blob'); + return { blob, paper }; + } + async function captureElementToBlob(el, snapshot, rect) { try { if (document.fonts?.ready) await document.fonts.ready; } catch {} const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); @@ -5261,6 +6297,13 @@ scale: Math.min(window.devicePixelRatio || 1, 2), font: fontCssText ? { cssText: fontCssText } : undefined, }; + if (shouldUseAncestorCropShaderProxy(el)) { + try { + return await hideCaptureChromeForShaderProxy(() => captureElementFromRenderedAncestor(ms, el, opts)); + } catch (err) { + console.warn('[impeccable] Svelte ancestor crop capture failed, falling back to element capture:', err); + } + } const bg = resolveCanvasBackground(el); // Fast path: the element paints its own background, or an opaque ancestor // color was found. modern-screenshot bakes that color; paper matches it. @@ -5342,13 +6385,13 @@ sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); } - // --------------------------------------------------------------------------- + // // Shader overlay - renders the captured screenshot as a WebGL texture and // runs an editorial "ink-wash" fragment shader over it during generation. // A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku // and leaving a soft trail. Makes the wait feel like a letterpress scan // instead of a dead spinner. - // --------------------------------------------------------------------------- + // const SHADER_VS = `attribute vec2 a_position; attribute vec2 a_uv; @@ -5502,6 +6545,31 @@ void main() { return n ? [r / n / 255, g / n / 255, b / n / 255] : SHADER_PAPER_FALLBACK; } + // Pick the most common visible color cluster from a crop. A straight average + // gets pulled by text and icons; the dominant bucket usually represents the + // surface the shader should dissolve into. + function dominantRgb01(ctx, w, h) { + const data = ctx.getImageData(0, 0, w, h).data; + const stride = Math.max(1, Math.floor((w * h) / 6000)); + const buckets = new Map(); + for (let p = 0; p < w * h; p += stride) { + const i = p * 4; + if (data[i + 3] < 16) continue; + const key = (data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4); + const bucket = buckets.get(key) || { count: 0, r: 0, g: 0, b: 0 }; + bucket.count += 1; + bucket.r += data[i]; + bucket.g += data[i + 1]; + bucket.b += data[i + 2]; + buckets.set(key, bucket); + } + let best = null; + for (const bucket of buckets.values()) { + if (!best || bucket.count > best.count) best = bucket; + } + return best ? [best.r / best.count / 255, best.g / best.count / 255, best.b / best.count / 255] : null; + } + // Average the backdrop sampled just OUTSIDE an element's rect within a larger // canvas. The ground tone for the dissolve must be the real backdrop, not the // mean of the element's own crop - averaging the crop folds in the element's @@ -5572,7 +6640,7 @@ void main() { fallback.style.backgroundRepeat = 'no-repeat'; fallback.style.outline = '2px dashed ' + C.brand; fallback.style.outlineOffset = '-2px'; - document.body.appendChild(fallback); + uiAppend(fallback); shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl }; } @@ -5582,16 +6650,19 @@ void main() { const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); + const radius = getComputedStyle(el).borderRadius; canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); Object.assign(canvas.style, { position: 'fixed', top: rect.top + 'px', left: rect.left + 'px', width: rect.width + 'px', height: rect.height + 'px', + borderRadius: radius, + overflow: 'hidden', pointerEvents: 'none', zIndex: Z.bar - 1, }); - document.body.appendChild(canvas); + uiAppend(canvas); const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) || canvas.getContext('experimental-webgl'); @@ -5685,8 +6756,12 @@ void main() { frame(); } - function handleAccept() { + async function handleAccept() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } + if (pendingAcceptedSession || state === 'SAVING') return; + if (variantSelectionPromise) { + try { await variantSelectionPromise; } catch { /* failed selection falls back below */ } + } if (!currentSessionId || arrivedVariants === 0) return; const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId); if (domVisibleVariant > 0) visibleVariant = domVisibleVariant; @@ -5696,30 +6771,39 @@ void main() { variantId: String(visibleVariant), pageUrl: location.pathname, }; + const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } + // The accepted variant is already the only visible child of the wrapper + // (all other variants are display:none). HMR from the source rewrite will + // replace the wrapper imminently. Don't eagerly replaceChild here - React + // reconciliation races with our mutation and throws NotFoundError in Next + // 16 / Turbopack. Schedule a fallback that runs the manual swap only if + // HMR hasn't cleaned up by then (keeps static-server flows working). const acceptedSessionId = currentSessionId; const acceptedVariant = visibleVariant; + const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId + || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - pendingAcceptedSession = { - id: acceptedSessionId, - variant: String(acceptedVariant), - ...acceptedSnapshot, - finalizing: false, - }; state = 'SAVING'; updateBarContent('saving'); + pendingAcceptedSession = { + id: acceptedSessionId, + variant: String(acceptedVariant), + isSvelteComponent: acceptedIsSvelteComponent, + ...acceptedSnapshot, + finalizing: false, + }; + saveSession(); sendEvent(acceptPayload, { throwOnError: true }) - .then(() => { - markSessionHandled(); - }) + .then(() => {}) .catch(() => { - pendingAcceptedSession = null; + if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); } @@ -5733,19 +6817,21 @@ void main() { } if (pending.finalizing) return true; pending.finalizing = true; - + markSessionHandled(); + if (pending.isSvelteComponent) { + commitAcceptedSvelteComponentToDom(pending.id); + } state = 'CONFIRMED'; updateBarContent('confirmed'); + scheduleAcceptCleanup(pending); + return true; + } - // Give framework HMR a short chance to render the now-clean accepted - // source. If it misses the update, unwrap the accepted variant after the - // source-side completion event so the page is not left empty or stale. + function scheduleAcceptCleanup(accepted) { setTimeout(function() { - ensureAcceptedDomClean(pending); + if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted); cleanupAcceptedSession(); }, 1200); - - return true; } function snapshotAcceptedVariantDom(sessionId, variantId) { @@ -5833,6 +6919,7 @@ void main() { stopScrollLock(); clearScrollY(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5841,6 +6928,28 @@ void main() { state = 'PICKING'; } + function commitAcceptedVariantToDom(sessionId, variantId) { + const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapper) return false; + const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); + if (!accepted || !accepted.firstElementChild) return false; + const parent = wrapper.parentElement; + if (!parent) return false; + + const style = wrapper.querySelector('style[data-impeccable-css]'); + if (style && !document.querySelector('style[data-impeccable-accepted-css="' + sessionId + '"]')) { + const promotedStyle = style.cloneNode(true); + promotedStyle.setAttribute('data-impeccable-accepted-css', sessionId); + parent.insertBefore(promotedStyle, wrapper); + } + + const committed = accepted.cloneNode(true); + committed.removeAttribute('hidden'); + committed.style.display = 'contents'; + parent.replaceChild(committed, wrapper); + return true; + } + function handleDiscard() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!currentSessionId) return; @@ -5852,11 +6961,141 @@ void main() { .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000)); } - // --------------------------------------------------------------------------- + // // Session persistence via live-browser-session.js - // --------------------------------------------------------------------------- + // // Survives page reloads, browser close/reopen, HMR, and accidental refreshes. + function normalizeSessionPath(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed.replace(/\\/g, '/') : null; + } + + function resetSessionFileMeta() { + currentSourceFile = null; + currentPreviewFile = null; + currentPreviewMode = null; + recoveryWaitingForAnchor = false; + } + + function rememberSessionFileMeta(meta = {}) { + const file = normalizeSessionPath(meta.file); + const sourceFile = normalizeSessionPath(meta.sourceFile); + const previewFile = normalizeSessionPath(meta.previewFile); + const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null); + + if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) { + currentPreviewMode = 'svelte-component'; + currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile); + currentSourceFile = sourceFile || currentSourceFile; + return; + } + + if (sourceFile || file) currentSourceFile = sourceFile || file; + if (previewFile) currentPreviewFile = previewFile; + if (previewMode) currentPreviewMode = previewMode; + } + + function applySavedSessionMeta(saved) { + if (!saved) return; + rememberSessionFileMeta(saved); + if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.action) selectedAction = saved.action; + if (saved.count) selectedCount = saved.count; + if (saved.previewMode) currentPreviewMode = saved.previewMode; + if (saved.paramValues && typeof saved.paramValues === 'object') { + paramsCurrentValues = { ...saved.paramValues }; + } + } + + function normalizePagePath(value) { + if (!value || typeof value !== 'string') return null; + try { + return new URL(value, location.origin).pathname; + } catch { + return value.split(/[?#]/)[0] || null; + } + } + + function pageMatchesCurrent(value) { + const path = normalizePagePath(value); + return !path || path === location.pathname; + } + + function isTerminalSessionSummary(session) { + return /^(completed|discarded|discard_requested|accept_requested)$/.test(String(session?.phase || '')); + } + + function findActiveSessionSummary(saved, activeSessions) { + if (!saved?.id || !Array.isArray(activeSessions)) return null; + return activeSessions.find((session) => + session?.id === saved.id + && pageMatchesCurrent(session.pageUrl || saved.pageUrl) + && !isTerminalSessionSummary(session) + ) || null; + } + + function clampVariantIndex(value, count) { + const num = Number(value); + const max = Number(count); + if (!Number.isFinite(num) || num < 1) return 0; + if (Number.isFinite(max) && max > 0 && num > max) return 0; + return Math.floor(num); + } + + function restoreSessionWithoutWrapper(reason, activeSessions) { + const saved = loadSession(); + if (!saved?.id || isSessionHandled(saved.id)) return false; + const savedState = String(saved.state || '').toUpperCase(); + if (savedState !== 'GENERATING' && savedState !== 'CYCLING') return false; + + const serverSession = findActiveSessionSummary(saved, activeSessions); + if (Array.isArray(activeSessions) && activeSessions.length > 0 && !serverSession) { + return false; + } + + currentSessionId = saved.id; + applySavedSessionMeta(serverSession); + applySavedSessionMeta(saved); + + expectedVariants = Number(saved.expected || serverSession?.expectedVariants || selectedCount || 0); + arrivedVariants = Number(saved.arrived || serverSession?.arrivedVariants || 0); + if (arrivedVariants <= 0 && currentPreviewFile) arrivedVariants = Number(serverSession?.expectedVariants || saved.expected || selectedCount || 0); + if (expectedVariants <= 0) expectedVariants = Number(serverSession?.expectedVariants || arrivedVariants || selectedCount || 0); + visibleVariant = clampVariantIndex(saved.visible, arrivedVariants || expectedVariants) + || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) + || (arrivedVariants > 0 ? 1 : 0); + + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + if (variantObserver) variantObserver.disconnect(); + variantObserver = startVariantObserver(currentSessionId); + saveSession(); + queueCheckpoint(reason || 'browser_restore_without_wrapper'); + + const restoreFile = currentPreviewMode === 'svelte-component' + ? currentPreviewFile + : (currentSourceFile || currentPreviewFile); + if (restoreFile) { + injectVariantsFromSource(restoreFile, currentSessionId); + return true; + } + + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return true; + } + + function restoreFromActiveSessions(activeSessions, reason) { + const wrapper = document.querySelector('[data-impeccable-variants]'); + if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false; + if (svelteComponentSession?.sessionId === currentSessionId) return false; + return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); + } + function saveSession() { if (!currentSessionId) return; // NOTE: scrollY is stored under a separate key (writeScrollY). Storing @@ -5869,6 +7108,11 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, + pageUrl: location.pathname, + paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, }); } @@ -5898,31 +7142,33 @@ void main() { } function cleanup() { - // Hide the wrapper immediately so variants disappear. DON'T structurally - // mutate the DOM yet - HMR from the agent's source rewrite is on its way, - // and a manual replaceChild under React causes NotFoundError when the - // reconciler later tries to remove a wrapper we already removed. - // Schedule a 2s fallback that does the manual swap only if HMR hasn't - // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const cleanupSessionId = currentSessionId; - if (cleanupSessionId) { + if (svelteComponentSession?.sessionId === cleanupSessionId) { + teardownSvelteComponentSession(true); + } else if (cleanupSessionId) { + // Hide the wrapper immediately so variants disappear. DON'T structurally + // mutate the DOM yet - HMR from the agent's source rewrite is on its way, + // and a manual replaceChild under React causes NotFoundError when the + // reconciler later tries to remove a wrapper we already removed. + // Schedule a 2s fallback that does the manual swap only if HMR hasn't + // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); if (wrapper) wrapper.style.display = 'none'; - } - setTimeout(function() { - if (!cleanupSessionId) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!wrapper) return; - const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - wrapper.parentElement.replaceChild(content, wrapper); - return; + setTimeout(function() { + if (!cleanupSessionId) return; + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) return; + const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); + if (orig) { + const content = orig.firstElementChild; + if (content) { + lateWrapper.parentElement.replaceChild(content, lateWrapper); + return; + } } - } - wrapper.remove(); - }, 2000); + lateWrapper.remove(); + }, 2000); + } hideBar(); hideHighlight(); stopScrollTracking(); @@ -5931,6 +7177,7 @@ void main() { clearScrollY(); finalizeInsertSession(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5938,9 +7185,9 @@ void main() { state = 'PICKING'; } - // --------------------------------------------------------------------------- + // // Toast - // --------------------------------------------------------------------------- + // function showToast(message, duration) { if (toastEl) toastEl.remove(); @@ -5964,7 +7211,7 @@ void main() { }); toastEl.id = PREFIX + '-toast'; toastEl.textContent = message; - document.body.appendChild(toastEl); + uiAppend(toastEl); requestAnimationFrame(() => { toastEl.style.opacity = '1'; toastEl.style.transform = 'translateX(-50%) translateY(0)'; @@ -5978,22 +7225,70 @@ void main() { }, duration); } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. function resumeSession() { const wrapper = document.querySelector('[data-impeccable-variants]'); - if (!wrapper) { clearSession(); clearHandled(); return false; } + if (!wrapper) { + if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } const sessionId = wrapper.dataset.impeccableVariants; // Don't resume if this session was already accepted/discarded if (isSessionHandled(sessionId)) return false; + // Svelte component sessions can't be resumed by counting DOM children: the + // wrapper holds a single mount target, not [data-impeccable-variant] nodes, + // and a page reload unmounts every compiled variant. Counting children here + // would strand the bar in CYCLING at 0/0. If there's no live in-memory mount + // for this wrapper, it's an orphan (reload / failed mount): drop it and let + // the live-server's SSE re-inject the manifest if the session is still live. + if (wrapper.dataset.impeccablePreview === 'svelte-component' + && svelteComponentSession?.sessionId !== sessionId) { + wrapper.remove(); + if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } + + if (wrapper.dataset.impeccablePreview === 'svelte-component') { + if (!svelteComponentSession?.mountedVariant) { + return true; + } + currentSessionId = sessionId; + expectedVariants = Number(wrapper.dataset.impeccableVariantCount) + || Number(svelteComponentSession.manifest?.count) + || expectedVariants + || 1; + arrivedVariants = expectedVariants; + const saved = loadSession(); + applySavedSessionMeta(saved); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = svelteComponentSession.mountedVariant > 0 && svelteComponentSession.mountedVariant <= arrivedVariants + ? svelteComponentSession.mountedVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = resolveSvelteComponentAnchor() + || wrapper.parentElement; + state = 'CYCLING'; + hideShaderOverlay(); + showBar('cycling'); + startScrollTracking(); + refreshParamsPanel(); + saveSession(); + queueCheckpoint('browser_resumed_svelte_component'); + return true; + } + currentSessionId = sessionId; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -6002,6 +7297,7 @@ void main() { // Restore state from localStorage if available const saved = loadSession(); if (saved && saved.id === sessionId) { + applySavedSessionMeta(saved); visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0); if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; @@ -6072,9 +7368,9 @@ void main() { return true; } - // --------------------------------------------------------------------------- + // // Global bar (always visible at bottom) - // --------------------------------------------------------------------------- + // let globalBarEl = null; let globalBarBrandEl = null; @@ -6166,6 +7462,8 @@ void main() { let pageChatExpanded = false; let steerLocked = false; let steerRequestId = null; + let steerPendingMessage = ''; + let steerInputWasFocused = false; let pageChatDotsEl = null; let steerAwaitTimer = null; let voiceRecognition = null; @@ -6179,7 +7477,7 @@ void main() { const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; - const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect'; + const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect'; const GLOBAL_BAR_SECTION_GAP = 8; const GLOBAL_BAR_INNER_GAP = 2; const GLOBAL_BAR_INNER_PAD_LEFT = 2; @@ -6323,7 +7621,7 @@ void main() { const attempt = () => { steerFocusRecoverTimer = null; if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (pageHasHostTextSelection()) { steerFocusRecoverTimer = setTimeout(attempt, 120); return; @@ -6344,7 +7642,7 @@ void main() { steerFocusSuspended = true; steerFocusPauseUntil = performance.now() + STEER_PAGE_FOCUS_PAUSE_MS; pagePointerGesture = { x: e.clientX, y: e.clientY, dragged: false }; - if (pageChatInput && document.activeElement === pageChatInput) { + if (pageChatInput && activeElementDeep() === pageChatInput) { pageChatInput.blur(); } } @@ -6404,7 +7702,7 @@ void main() { pickActive, pageChatReady: !!pageChatInput, pageChatExpanded, - active: steerFocusTargetLabel(document.activeElement), + active: steerFocusTargetLabel(activeElementDeep()), shouldSteer: shouldFocusSteerChat(), ...(extra || {}), }); @@ -6423,26 +7721,26 @@ void main() { function focusConfigureInput(reason) { steerFocusLog('focusConfigureInput', { reason }); const inputId = configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input'; - const input = document.getElementById(inputId); + const input = uiGetById(inputId); if (!input) { steerFocusLog('focusConfigureInput missing', { reason }); return; } setTimeout(() => { - const before = document.activeElement; + const before = activeElementDeep(); input.focus(); steerFocusLog('focusConfigureInput result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== input, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== input, }); }, 60); } function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; - const focused = document.activeElement === pageChatInput; + const focused = activeElementDeep() === pageChatInput; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; const P = pageChatPalette(); pageChatEl.style.borderColor = steerLocked @@ -6476,15 +7774,15 @@ void main() { } syncPageChatVisual(); pageChatInput.style.pointerEvents = 'auto'; - const before = document.activeElement; + const before = activeElementDeep(); try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== pageChatInput, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== pageChatInput, }); } @@ -6515,6 +7813,37 @@ void main() { return wrap; } + function keepSteerPointerInside(e, opts = {}) { + e.stopPropagation(); + if (opts.preventDefault !== false) e.preventDefault(); + } + + function preparePageChatInputForTyping() { + if (!pageChatEl || !pageChatInput) return false; + pageChatExpanded = true; + pageChatEl.dataset.expanded = 'true'; + pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; + pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return true; + } + + function focusPageChatInput(reason) { + if (!preparePageChatInputForTyping() || steerLocked) return false; + try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } + const focused = activeElementDeep() === pageChatInput; + if (focused) steerInputWasFocused = true; + syncPageChatFocusRing(); + return focused; + } + function clearSteerAwaitTimer() { if (steerAwaitTimer) { clearTimeout(steerAwaitTimer); @@ -6528,6 +7857,7 @@ void main() { if (!steerLocked || steerRequestId !== id) return; unlockSteerChat({ error: 'Steer timed out waiting for the agent. Check that live-poll is running and replies with steer_done.', + restoreMessage: steerPendingMessage, }); }, STEER_AWAIT_TIMEOUT_MS); } @@ -6538,19 +7868,12 @@ void main() { steerLocked = true; pageChatEl.dataset.processing = 'true'; pageChatInput.disabled = true; - pageChatInput.value = ''; - pageChatInput.blur(); + preparePageChatInputForTyping(); if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = true; pageChatVoiceBtn.style.display = 'none'; } - pageChatExpanded = false; - pageChatEl.dataset.expanded = 'false'; - pageChatEl.style.width = PAGE_CHAT_PROCESSING_W; pageChatEl.style.cursor = 'default'; - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; pageChatInput.style.pointerEvents = 'none'; if (pageChatHint) { pageChatHint.style.display = 'none'; @@ -6568,17 +7891,26 @@ void main() { function unlockSteerChat(opts) { clearSteerAwaitTimer(); + const restoreMessage = typeof opts?.restoreMessage === 'string' ? opts.restoreMessage : ''; + const keepExpanded = Boolean(opts?.error && restoreMessage); steerLocked = false; + const completedId = steerRequestId; steerRequestId = null; if (!pageChatEl) return; pageChatEl.dataset.processing = 'false'; pageChatEl.removeAttribute('aria-busy'); pageChatEl.setAttribute('aria-label', 'Steer the page'); - pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W; + pageChatExpanded = keepExpanded; + pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false'; + pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W; pageChatEl.style.cursor = 'pointer'; if (pageChatInput) { pageChatInput.disabled = false; - pageChatInput.value = ''; + pageChatInput.value = keepExpanded ? restoreMessage : ''; + pageChatInput.style.width = keepExpanded ? '' : '0'; + pageChatInput.style.padding = keepExpanded ? '0 6px' : '0'; + pageChatInput.style.opacity = keepExpanded ? '1' : '0'; + pageChatInput.style.pointerEvents = 'auto'; } if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = false; @@ -6586,18 +7918,28 @@ void main() { } if (pageChatHint) { pageChatHint.textContent = 'Steer'; - pageChatHint.style.display = ''; - pageChatHint.style.visibility = ''; + pageChatHint.style.display = keepExpanded ? 'none' : ''; + pageChatHint.style.visibility = keepExpanded ? 'hidden' : ''; + pageChatHint.style.opacity = keepExpanded ? '0' : '1'; } if (pageChatDotsEl?.parentNode) { pageChatDotsEl.remove(); pageChatDotsEl = null; } + steerPendingMessage = keepExpanded ? restoreMessage : ''; + steerInputWasFocused = false; syncPageChatChrome(); syncPageChatFocusRing(); if (opts?.error) showToast(String(opts.error), 5000); else if (opts?.message) showToast(String(opts.message), 4000); - syncPageChatFocus('steer-unlock'); + if (completedId) { + sendSteerCheckpoint(completedId, opts?.error ? 'steer_error' : 'steer_done', { + message: opts?.message || opts?.error || '', + file: opts?.file || '', + }); + } + if (keepExpanded) focusPageChatInput('steer-error-restore'); + else syncPageChatFocus('steer-unlock'); } function steerSpeechRecognitionCtor() { @@ -6651,7 +7993,7 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = document.getElementById(PREFIX + '-configure-voice'); + const voiceBtn = uiGetById(PREFIX + '-configure-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -6784,7 +8126,7 @@ void main() { } function configureVoiceContext() { - const input = document.getElementById( + const input = uiGetById( configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input', ); return { @@ -6819,26 +8161,37 @@ void main() { if (!text || steerLocked) return; const id = id8(); steerRequestId = id; + steerPendingMessage = text; + if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); lockSteerChat(); scheduleSteerAwaitTimeout(id); + sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); sendEvent({ type: 'steer', id, message: text, pageUrl: location.href, }).then((res) => { - if (!res) unlockSteerChat({ error: 'Could not reach live server' }); + if (!res) { + sendSteerCheckpoint(id, 'steer_send_failed', { message: text }); + unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text }); + } }); } function maybeCompleteSteer(msg) { if (!steerRequestId || msg.id !== steerRequestId) return false; if (msg.type === 'steer_done') { - unlockSteerChat({ message: msg.message }); + unlockSteerChat({ message: msg.message, file: msg.file }); + if (msg.file && /\.svelte(?:$|\?)/.test(String(msg.file))) { + setTimeout(() => { + if (!steerLocked) showToast('Steer applied. Reload if the page has not refreshed yet.', 5000); + }, 4500); + } return true; } if (msg.type === 'error') { - unlockSteerChat({ error: msg.message || 'Steer failed' }); + unlockSteerChat({ error: msg.message || 'Steer failed', restoreMessage: steerPendingMessage }); return true; } return false; @@ -6847,21 +8200,10 @@ void main() { function expandPageChat(opts) { const focus = !opts || opts.focus !== false; if (!pageChatEl || !pageChatInput || steerLocked) return; - pageChatExpanded = true; - pageChatEl.dataset.expanded = 'true'; - pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; - pageChatEl.style.cursor = 'text'; - if (pageChatHint) { - pageChatHint.style.display = 'none'; - pageChatHint.style.opacity = '0'; - } - pageChatInput.style.width = ''; - pageChatInput.style.padding = '0 6px'; - pageChatInput.style.opacity = '1'; - pageChatInput.style.pointerEvents = 'auto'; + preparePageChatInputForTyping(); syncPageChatChrome(); syncPageChatFocusRing(); - if (focus) pageChatInput.focus(); + if (focus) focusPageChatInput('expand-page-chat'); } function collapsePageChat(opts) { @@ -6878,7 +8220,7 @@ void main() { } else { pageChatInput.style.pointerEvents = 'auto'; } - if (pageChatHint && document.activeElement !== pageChatInput) { + if (pageChatHint && activeElementDeep() !== pageChatInput) { pageChatHint.style.display = ''; pageChatHint.style.opacity = '1'; } @@ -6952,7 +8294,7 @@ void main() { pageChatEl.appendChild(pageChatInput); pageChatEl.appendChild(pageChatVoiceBtn); - if (!document.getElementById(PREFIX + '-page-chat-style')) { + if (!uiGetById(PREFIX + '-page-chat-style')) { const s = document.createElement('style'); s.id = PREFIX + '-page-chat-style'; s.textContent = @@ -6966,23 +8308,34 @@ void main() { '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } - pageChatEl.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { + keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat(); + expandPageChat({ focus: false }); + focusPageChatInput('page-chat-click'); }); - pageChatVoiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); + pageChatVoiceBtn.addEventListener('mousedown', keepSteerPointerInside); pageChatVoiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); + keepSteerPointerInside(e); if (steerLocked) return; toggleSteerVoice(); }); + pageChatInput.addEventListener('pointerdown', keepSteerPointerInside); + pageChatInput.addEventListener('mousedown', keepSteerPointerInside); + pageChatInput.addEventListener('click', (e) => { + keepSteerPointerInside(e); + if (!steerLocked) focusPageChatInput('page-chat-input-click'); + }); + pageChatInput.addEventListener('input', () => { syncPageChatVisual(); }); @@ -6995,7 +8348,7 @@ void main() { syncPageChatFocusRing(); setTimeout(() => { if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (!pageChatInput.value.trim()) collapsePageChat(); scheduleSteerFocusRecover('steer-blur-recover'); }, 120); @@ -7039,7 +8392,7 @@ void main() { globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false'; globalBarBrandEl.setAttribute('aria-label', connected ? 'Impeccable live mode' - : 'Impeccable live mode: agent not polling'); + : 'Impeccable live mode - agent not polling'); globalBarBrandEl.removeAttribute('title'); globalBarBrandEl.style.cursor = connected ? 'default' : 'help'; const mark = globalBarBrandEl.querySelector('[data-brand-mark]'); @@ -7077,7 +8430,7 @@ void main() { }); agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip'; agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP; - document.body.appendChild(agentPollTooltipEl); + uiAppend(agentPollTooltipEl); return agentPollTooltipEl; } @@ -7131,7 +8484,7 @@ void main() { // Custom focus-visible for bar buttons. Browser default is a heavy // blue ring that looks jarring on the dark capsule. Replace with a // soft accent-tinted inner ring that respects the bar's palette. - if (!document.getElementById(PREFIX + '-bar-focus-style')) { + if (!uiGetById(PREFIX + '-bar-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-bar-focus-style'; s.textContent = @@ -7143,7 +8496,7 @@ void main() { '@keyframes impeccable-agent-dot { 0%, 100% { opacity: 0.45; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1); } }' + '#' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: impeccable-agent-dot 1.4s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: none; opacity: 0.9; } }'; - document.head.appendChild(s); + uiAppendStyle(s); } globalBarEl = el('div', { @@ -7176,7 +8529,7 @@ void main() { brand.id = PREFIX + '-global-bar-brand'; brand.dataset.agentConnected = 'false'; brand.setAttribute('role', 'img'); - brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling'); + brand.setAttribute('aria-label', 'Impeccable live mode - agent not polling'); const brandMark = el('span', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -7211,7 +8564,7 @@ void main() { inner.id = PREFIX + '-global-bar-inner'; globalBarEl.appendChild(inner); - // --- button factory: icon-only at rest, label slides in on hover/active --- + // Button factory: icon-only at rest, label slides in on hover/active. function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { const b = el('button', { position: 'relative', @@ -7506,6 +8859,7 @@ void main() { color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', }); + exitBtn.id = PREFIX + '-exit'; exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = 'oklch(58% 0.15 35)'; exitBtn.style.background = P.exitHover; }); @@ -7530,8 +8884,8 @@ void main() { try { window.focus(); } catch { /* in-app preview may block */ } }, true); - document.body.appendChild(pendingDockEl); - document.body.appendChild(globalBarEl); + uiAppend(pendingDockEl); + uiAppend(globalBarEl); defangOutsideHandlers(pendingDockEl); defangOutsideHandlers(globalBarEl); @@ -7553,11 +8907,11 @@ void main() { } function updateGlobalBarState() { - const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); - const detectBadge = document.getElementById(PREFIX + '-detect-badge'); - const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); - const insertToggle = document.getElementById(PREFIX + '-insert-toggle'); - const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const detectToggle = uiGetById(PREFIX + '-detect-toggle'); + const detectBadge = uiGetById(PREFIX + '-detect-badge'); + const pickToggle = uiGetById(PREFIX + '-pick-toggle'); + const insertToggle = uiGetById(PREFIX + '-insert-toggle'); + const designToggle = uiGetById(PREFIX + '-design-toggle'); const theme = globalBarEl?.dataset.theme || 'light'; const P = barPaletteForTheme(theme); @@ -7751,8 +9105,9 @@ void main() { pendingApplyInFlight = false; } if (globalBarEl) { - globalBarEl.style.transform = 'translateY(100%)'; - setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300); + globalBarEl.style.transition = 'none'; + globalBarEl.remove(); + globalBarEl = null; } pageChatEl = null; pageChatInput = null; @@ -7765,6 +9120,7 @@ void main() { if (barEl) { barEl.remove(); barEl = null; } if (pickerEl) { pickerEl.remove(); pickerEl = null; } if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; } + if (editBadgeProxyRoot) { editBadgeProxyRoot.remove(); editBadgeProxyRoot = null; editBadgeProxyByTarget = new Map(); } if (evtSource) { evtSource.close(); evtSource = null; } document.removeEventListener('mousemove', handleMouseMove, true); document.removeEventListener('click', handleClick, true); @@ -7777,9 +9133,9 @@ void main() { console.log('[impeccable] Live mode exited.'); } - // --------------------------------------------------------------------------- + // // Design System Panel - visualizes the project's .impeccable/design.json sidecar - // --------------------------------------------------------------------------- + // const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; const DESIGN_PANEL_WIDTH = 440; @@ -7847,7 +9203,7 @@ void main() { root.className = 'root'; designShadow.appendChild(root); - document.body.appendChild(designHost); + uiAppend(designHost); // The host is pointer-events: none; the panel inside the shadow DOM // manages its own auto/none. Events bubble through the shadow boundary, // so attaching here silences host-page outside-interaction handlers @@ -7889,7 +9245,7 @@ void main() { .root * { box-sizing: border-box; } button { font: inherit; color: inherit; } - /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + /* Panel shell: chrome matches the bar; body canvas stays neutral */ .panel { position: fixed; top: 12px; bottom: 72px; right: 12px; width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); @@ -7955,7 +9311,7 @@ void main() { .panel-body::-webkit-scrollbar { width: 8px; } .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } - /* --- States --- */ + /* States */ .empty, .loading, .error { margin: 16px 4px; padding: 28px 20px; text-align: center; @@ -7966,7 +9322,7 @@ void main() { .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; } .error { color: oklch(45% 0.15 25); } - /* --- Stale hint --- */ + /* Stale hint */ .stale { display: flex; align-items: center; gap: 8px; margin: 8px 4px 12px; @@ -7979,7 +9335,7 @@ void main() { .stale-text { flex: 1; min-width: 0; } .stale-text strong { color: ${DP.ink}; font-weight: 600; } - /* --- Parsed-md fallback banner --- */ + /* Parsed-md fallback banner */ .parsed-md-cta { margin: 8px 4px 14px; padding: 14px 16px; @@ -7991,7 +9347,7 @@ void main() { .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; } .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; } - /* --- Tile primitives --- */ + /* Tile primitives */ .tile { position: relative; background: ${DP.tile}; @@ -8010,7 +9366,7 @@ void main() { } .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; } - /* --- Color tile --- */ + /* Color tile */ .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; } .c-tile:hover { transform: translateY(-1px); } .c-hero { @@ -8025,7 +9381,7 @@ void main() { .c-ramp > span { flex: 1; } .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Type tile --- */ + /* Type tile */ .t-tile { } .t-specimen { margin: 4px 0 6px; @@ -8035,7 +9391,7 @@ void main() { .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; } .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Shadow tile --- */ + /* Shadow tile */ .s-tile { } .s-surface { height: 60px; margin: 8px 2px 10px; @@ -8045,14 +9401,14 @@ void main() { .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; } .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; } - /* --- Radii strip --- */ + /* Radii strip */ .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; } .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; } .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); } .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; } .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; } - /* --- Component tile (hosts live primitives) --- */ + /* Component tile (hosts live primitives) */ .cmp-tile { } .cmp-stage { margin: 12px -4px 0; @@ -8066,7 +9422,7 @@ void main() { .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; } .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; } - /* --- Collapsible --- */ + /* Collapsible */ .coll { margin: 0 4px 8px; background: ${DP.tile}; @@ -8131,7 +9487,7 @@ void main() { .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; } .coll .overview-body li { margin-bottom: 3px; } - /* --- raw tab markdown (unchanged layout, neutralized palette) --- */ + /* raw tab markdown (unchanged layout, neutralized palette) */ .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; } .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; } .md h1 { font-size: 18px; } @@ -8303,7 +9659,7 @@ void main() { return box; } - // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 --- + // Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 function renderDesignVisual(body, parsed, sidecar) { const frontmatter = parsed?.frontmatter || {}; @@ -8667,7 +10023,7 @@ void main() { return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components'); } - // --- Collapsibles --------------------------------------------------------- + // Collapsibles. function buildCollapsible(key, label, count) { const wrap = document.createElement('div'); @@ -8775,7 +10131,7 @@ void main() { return s.replace(/\s+#.*$/, '').trim(); } - // --- Raw tab: minimal markdown renderer (subset) -------------------------- + // Raw tab: minimal markdown renderer (subset) function renderRawTab(body, md) { const wrap = document.createElement('div'); @@ -8908,9 +10264,9 @@ void main() { } catch { /* ignore */ } } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // function init() { try { history.scrollRestoration = 'manual'; } catch {} diff --git a/.gemini/skills/impeccable/scripts/live-completion.mjs b/.gemini/skills/impeccable/scripts/live-completion.mjs index 86b637fff..986773066 100644 --- a/.gemini/skills/impeccable/scripts/live-completion.mjs +++ b/.gemini/skills/impeccable/scripts/live-completion.mjs @@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.mode === 'error') return 'error'; + if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error'; return 'agent_done'; } diff --git a/.gemini/skills/impeccable/scripts/live-inject.mjs b/.gemini/skills/impeccable/scripts/live-inject.mjs index b9d3df41d..3a1f36e46 100644 --- a/.gemini/skills/impeccable/scripts/live-inject.mjs +++ b/.gemini/skills/impeccable/scripts/live-inject.mjs @@ -17,11 +17,38 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './impeccable-paths.mjs'; +import { + applySvelteKitLiveAdapter, + detectSvelteKitProject, + removeSvelteKitLiveAdapter, +} from './live-sveltekit-adapter.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start'; +const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end'; + +export const LIVE_IGNORE_PATTERNS = Object.freeze([ + '.impeccable/hook.cache.json', + '.impeccable/live/server.json', + '.impeccable/live/sessions/', + '.impeccable/live/previews/', + '.impeccable/live/annotations/', + '.impeccable/live/cache/', + '.impeccable/live/manual-edit-apply-transaction.json', + '.impeccable/live/manual-edit-events.jsonl', + '.impeccable/live/manual-edit-evidence/', + '.impeccable/live/pending-manual-edits.json', + '.impeccable/live/deferred-svelte-component-accepts.json', + '.impeccable-live.json', + '.impeccable-live/', + 'node_modules/.impeccable-live/', + 'src/lib/impeccable/ImpeccableLiveRoot.svelte', + 'src/lib/impeccable/__runtime.js', + 'src/lib/impeccable/[0-9a-f]*/', +]); /** * Hard-excluded directory patterns. These are NEVER user-facing pages and @@ -83,8 +110,14 @@ Output (JSON): validateConfig(config); const resolvedFiles = resolveFiles(process.cwd(), config); + const svelteKit = detectSvelteKitProject(process.cwd(), config); if (args.includes('--remove')) { + if (svelteKit) { + const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config }); + console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; @@ -110,6 +143,13 @@ Output (JSON): console.error(JSON.stringify({ ok: false, error: 'missing_port' })); process.exit(1); } + const gitIgnore = ensureLiveGitIgnores(process.cwd()); + + if (svelteKit) { + const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config }); + console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); @@ -129,10 +169,68 @@ Output (JSON): }; }); const anyInserted = results.some((r) => r.inserted); - console.log(JSON.stringify({ ok: anyInserted, port, results })); + console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results })); if (!anyInserted) process.exit(1); } +export function ensureLiveGitIgnores(cwd = process.cwd()) { + const target = resolveIgnoreTarget(cwd); + const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : ''; + const block = [ + IGNORE_MARKER_OPEN, + ...LIVE_IGNORE_PATTERNS, + IGNORE_MARKER_CLOSE, + ].join('\n'); + const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`); + + let updated; + if (markerRe.test(existing)) { + updated = existing.replace(markerRe, block); + } else { + const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n'; + updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`; + } + + if (updated !== existing) { + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + fs.writeFileSync(target.path, updated, 'utf-8'); + } + + return { + file: path.relative(cwd, target.path).split(path.sep).join('/'), + mode: target.mode, + changed: updated !== existing, + patterns: [...LIVE_IGNORE_PATTERNS], + }; +} + +function resolveIgnoreTarget(cwd) { + const gitExcludePath = resolveGitInfoExcludePath(cwd); + if (gitExcludePath) { + return { path: gitExcludePath, mode: 'git-info-exclude' }; + } + return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' }; +} + +function resolveGitInfoExcludePath(cwd) { + const dotGit = path.join(cwd, '.git'); + if (!fs.existsSync(dotGit)) return null; + + const stat = fs.statSync(dotGit); + if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude'); + if (!stat.isFile()) return null; + + const body = fs.readFileSync(dotGit, 'utf-8').trim(); + const match = body.match(/^gitdir:\s*(.+)$/i); + if (!match) return null; + const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]); + return path.join(gitDir, 'info', 'exclude'); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Expand config.files (which may contain glob patterns) into a literal list * of existing file paths relative to rootDir. Literal entries pass through; diff --git a/.gemini/skills/impeccable/scripts/live-insert.mjs b/.gemini/skills/impeccable/scripts/live-insert.mjs index 09d4d55be..0658e9914 100644 --- a/.gemini/skills/impeccable/scripts/live-insert.mjs +++ b/.gemini/skills/impeccable/scripts/live-insert.mjs @@ -21,6 +21,11 @@ import { buildCssAuthoring, buildCssSelectorPrefixExamples, } from './live-wrap.mjs'; +import { + buildSvelteComponentCssAuthoring, + scaffoldSvelteComponentInsertSession, + shouldUseSvelteComponentInjection, +} from './live-svelte-component.mjs'; const INSERT_POSITIONS = new Set(['before', 'after']); @@ -192,6 +197,41 @@ Output (JSON): const styleMode = detectStyleMode(targetFile); const isJsx = commentSyntax.open === '{/*'; const spliceIndex = computeInsertLine(startLine, endLine, position); + const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); + + if (shouldUseSvelteComponentInjection(targetFile)) { + const session = scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile: relTargetFile, + insertLine: spliceIndex + 1, + position, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + anchorLines: lines.slice(startLine, endLine + 1), + cwd: process.cwd(), + }); + console.log(JSON.stringify({ + mode: 'insert', + position, + file: session.manifestFile, + sourceFile: relTargetFile, + previewMode: 'svelte-component', + componentDir: session.componentDir, + propContract: session.propContract, + insertLine: 1, + sourceInsertLine: spliceIndex + 1, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + commentSyntax, + styleMode: 'svelte-component', + styleTag: null, + cssSelectorPrefixExamples: [], + cssAuthoring: buildSvelteComponentCssAuthoring(count), + })); + return; + } + const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? ''; @@ -216,7 +256,7 @@ Output (JSON): console.log(JSON.stringify({ mode: 'insert', position, - file: path.relative(process.cwd(), targetFile), + file: relTargetFile, insertLine: insertLine + 1, commentSyntax, styleMode: styleMode.mode, diff --git a/.gemini/skills/impeccable/scripts/live-poll.mjs b/.gemini/skills/impeccable/scripts/live-poll.mjs index fad836612..1e1259fbc 100644 --- a/.gemini/skills/impeccable/scripts/live-poll.mjs +++ b/.gemini/skills/impeccable/scripts/live-poll.mjs @@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs'; // that ceiling and loop in `pollOnce` to synthesize a long poll without // depending on the standalone undici package. export const PER_REQUEST_TIMEOUT_MS = 270_000; +export const DEFAULT_EVENT_LEASE_MS = 600_000; const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); @@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) { ? totalDeadline - Date.now() : PER_REQUEST_TIMEOUT_MS; const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); - const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); + const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`); if (res.status === 401) { const err = new Error('Authentication failed. The server token may have changed.'); @@ -317,7 +318,7 @@ Modes: Options: --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) - --file PATH Attach a source file path to the reply (generate flow) + --file PATH Attach a source file path to the reply (generate/steer flow) --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --help Show this help message diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs index 16c8285b9..cd1091b88 100644 --- a/.gemini/skills/impeccable/scripts/live-server.mjs +++ b/.gemini/skills/impeccable/scripts/live-server.mjs @@ -42,6 +42,10 @@ import { } from './live-manual-edits-buffer.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs'; +import { + applyDeferredSvelteComponentAccepts, + removeAllSvelteComponentSessions, +} from './live-svelte-component.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated @@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; +const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2; const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); function tombstoneTimedOutApplyId(eventId, details = {}) { @@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) { return entry.event; } entry.leaseUntil = Date.now() + leaseMs; + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return entry.event; } @@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) { const acknowledged = state.pendingEvents[idx].event; state.pendingEvents.splice(idx, 1); scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return acknowledged; } +function findPendingEventById(id) { + if (!id) return null; + const entry = state.pendingEvents.find((item) => item.event?.id === id); + return entry?.event || null; +} + function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; return `live-poll.mjs --reply ${id} done --data ''`; @@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) { return summary; } +function summarizeActiveSessionForClient(snapshot = {}) { + return { + id: snapshot.id, + phase: snapshot.phase, + pageUrl: snapshot.pageUrl ?? null, + sourceFile: snapshot.sourceFile ?? null, + previewFile: snapshot.previewFile ?? null, + previewMode: snapshot.previewMode ?? null, + expectedVariants: snapshot.expectedVariants ?? 0, + arrivedVariants: snapshot.arrivedVariants ?? 0, + visibleVariant: snapshot.visibleVariant ?? null, + checkpointRevision: snapshot.checkpointRevision ?? 0, + paramValues: snapshot.paramValues || {}, + }; +} + +function activeSessionSummaries() { + if (!state.sessionStore) return []; + return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot)); +} + +function cancelQueuedAnonymousExitEvents() { + let removed = 0; + for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) { + const event = state.pendingEvents[i]?.event; + if (event?.type !== 'exit' || event.id) continue; + state.pendingEvents.splice(i, 1); + removed += 1; + } + if (removed > 0) { + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); + } + return removed; +} + function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { const canceledById = new Map(); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); @@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() { clearTimeout(state.leaseTimer); state.leaseTimer = null; } - if (state.pendingPolls.length === 0) return; const now = Date.now(); const nextLeaseUntil = state.pendingEvents .map((entry) => entry.leaseUntil || 0) @@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() { state.leaseTimer = setTimeout(() => { state.leaseTimer = null; flushPendingPolls(); - }, Math.max(0, nextLeaseUntil - now)); + broadcastAgentPollingIfChanged(); + }, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS)); } function flushPendingPolls() { @@ -1032,7 +1082,9 @@ function flushPendingPolls() { } function agentPollingConnected() { - return state.pendingPolls.length > 0; + const now = Date.now(); + return state.pendingPolls.length > 0 + || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now); } function broadcastAgentPollingIfChanged() { @@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/status') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } - const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; + const sessions = activeSessionSummaries(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', @@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/events' && req.method === 'GET') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + clearTimeout(state.exitTimer); + state.exitTimer = null; + cancelQueuedAnonymousExitEvents(); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { type: 'connected', hasProjectContext: hasProjectContext(), agentPolling: agentPollingConnected(), + activeSessions: activeSessionSummaries(), }) + '\n\n'); state.sseClients.add(res); - clearTimeout(state.exitTimer); // Keepalive: SSE comment every 30s prevents silent connection drops. const heartbeat = setInterval(() => { @@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } } + if (msg.type === 'exit') { + cleanupSvelteComponentSessionsBeforeExit(); + } if (msg.type !== 'checkpoint') { enqueueEvent(msg); } @@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) { }); } +function sessionFileMetadataFromPollReply(file) { + if (!file || typeof file !== 'string') return { file }; + const normalized = file.split(path.sep).join('/'); + const base = { file: normalized }; + if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base; + if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base; + + let full; + try { + full = path.resolve(process.cwd(), normalized); + const rel = path.relative(process.cwd(), full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base; + } catch { + return base; + } + + try { + const manifest = JSON.parse(fs.readFileSync(full, 'utf-8')); + if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base; + return { + file: String(manifest.sourceFile).split(path.sep).join('/'), + sourceFile: String(manifest.sourceFile).split(path.sep).join('/'), + previewFile: normalized, + previewMode: 'svelte-component', + }; + } catch { + return base; + } +} + function handlePollPost(req, res) { let body = ''; req.on('data', (c) => { body += c; }); @@ -1965,6 +2053,16 @@ function handlePollPost(req, res) { res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); return; } + const pendingEventBeforeAck = findPendingEventById(msg.id); + if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done' + && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'steer_done_requires_file_or_message', + hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.', + })); + return; + } const acknowledgedEvent = acknowledgePendingEvent(msg.id); let skipJournalReply = false; let existingSession = null; @@ -1987,6 +2085,7 @@ function handlePollPost(req, res) { })); return; } + const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); if (state.sessionStore && msg.id && !skipJournalReply) { try { const eventType = msg.type === 'steer_done' @@ -2001,7 +2100,10 @@ function handlePollPost(req, res) { state.sessionStore.appendEvent({ type: eventType, id: msg.id, - file: msg.file, + file: replyFileMeta.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, message: msg.message, sourceEventType: acknowledgedEvent?.type, carbonize: msg.data?.carbonize === true, @@ -2010,7 +2112,16 @@ function handlePollPost(req, res) { } flushPendingPolls(); // Forward the reply to the browser via SSE - broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + broadcast({ + type: msg.type || 'done', + id: msg.id, + message: msg.message, + file: msg.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, + data: msg.data, + }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); @@ -2023,6 +2134,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { + cleanupSvelteComponentSessionsBeforeExit(); removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; @@ -2037,6 +2149,25 @@ function shutdown() { process.exit(0); } +function cleanupSvelteComponentSessionsBeforeExit() { + try { + removeAllSvelteComponentSessions(process.cwd()); + } catch (err) { + console.warn('[impeccable] Svelte component session cleanup failed:', err.message); + } +} + +function applyLegacyDeferredAcceptsOnStartup() { + try { + const result = applyDeferredSvelteComponentAccepts(process.cwd()); + if (result.applied > 0 || result.failed > 0) { + console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result)); + } + } catch (err) { + console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({ cwd: process.cwd(), reason: 'manual_edit_server_start_recovered_abandoned_transaction', }); +applyLegacyDeferredAcceptsOnStartup(); restorePendingEventsFromStore(); pruneStaleManualApplyEvidence(process.cwd()); const portArg = args.find(a => a.startsWith('--port=')); diff --git a/.gemini/skills/impeccable/scripts/live-session-store.mjs b/.gemini/skills/impeccable/scripts/live-session-store.mjs index 7562e3d5b..5ec4d34d4 100644 --- a/.gemini/skills/impeccable/scripts/live-session-store.mjs +++ b/.gemini/skills/impeccable/scripts/live-session-store.mjs @@ -106,6 +106,8 @@ function baseSnapshot(id) { phase: 'new', pageUrl: null, sourceFile: null, + previewFile: null, + previewMode: null, expectedVariants: 0, arrivedVariants: 0, visibleVariant: null, @@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; - next.sourceFile = event.file ?? next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0); next.pendingEventSeq = null; next.pendingEvent = null; if (event.carbonize === true) { @@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { } break; case 'checkpoint': + if (COMPLETED_PHASES.has(next.phase)) { + next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null }); + break; + } if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { next.phase = event.phase ?? next.phase; next.checkpointRevision = event.revision ?? next.checkpointRevision; next.activeOwner = event.owner ?? next.activeOwner; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.visibleVariant = event.visibleVariant ?? next.visibleVariant; + next.sourceFile = event.sourceFile ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; if (event.paramValues) next.paramValues = { ...event.paramValues }; } else { next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); @@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'steer_done': next.phase = 'steer_done'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.message = event.message ?? next.message; next.pendingEventSeq = null; next.pendingEvent = null; break; @@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'complete': next.phase = 'completed'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; next.pendingEventSeq = null; next.pendingEvent = null; break; diff --git a/.gemini/skills/impeccable/scripts/live-svelte-component.mjs b/.gemini/skills/impeccable/scripts/live-svelte-component.mjs new file mode 100644 index 000000000..dc35dc0ce --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live-svelte-component.mjs @@ -0,0 +1,826 @@ +/** + * Svelte live-mode component injection helpers. + * + * Variants are real .svelte components under node_modules/.impeccable-live//. + * The browser mounts them via Svelte 5 mount(); accept inlines the chosen + * variant back into the route source with props mapped to original bindings. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; + +export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live'; +export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`; +export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json'; + +const MUSTACHE_RE = /\{([^{}]+)\}/g; + +export function shouldUseSvelteComponentInjection(filePath) { + if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false; + return path.extname(filePath).toLowerCase() === '.svelte'; +} + +export function componentSessionDir(id, cwd = process.cwd()) { + return path.join(cwd, SVELTE_COMPONENT_ROOT, id); +} + +export function manifestPathForSession(id, cwd = process.cwd()) { + return path.join(componentSessionDir(id, cwd), 'manifest.json'); +} + +export function ensureRuntimeHelper(cwd = process.cwd()) { + const file = path.join(cwd, SVELTE_RUNTIME_FILE); + if (fs.existsSync(file)) return file; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8'); + return file; +} + +/** + * Extract ordered unique mustache expressions from markup (not inside ). + */ +export function extractMustacheExpressions(text) { + const expressions = []; + const seen = new Set(); + const lines = String(text || '').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('\n` + : ''; + return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n\n`; +} + +function buildInsertVariantStub(variantNum) { + return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; +} + +export function scaffoldSvelteComponentSession({ + id, + count, + sourceFile, + sourceStartLine, + sourceEndLine, + originalLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const originalMarkup = originalLines.join('\n'); + const contract = buildPropContract(extractMustacheExpressions(originalMarkup)); + const originalWithProps = substituteExprsWithProps(originalMarkup, contract); + + const manifest = { + id, + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + sourceStartLine, + sourceEndLine, + count, + propContract: contract, + originalMarkup, + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: contract, + }; +} + +export function scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile, + insertLine, + position, + anchorStartLine, + anchorEndLine, + anchorLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const anchorMarkup = (anchorLines || []).join('\n'); + const manifest = { + id, + mode: 'insert', + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + insertLine, + position, + anchorStartLine, + anchorEndLine, + originalMarkup: anchorMarkup, + anchorMarkup, + count, + propContract: [], + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: [], + }; +} + +export function findSvelteComponentManifest(id, cwd = process.cwd()) { + const direct = manifestPathForSession(id, cwd); + if (fs.existsSync(direct)) { + return readManifest(direct); + } + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return null; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(root, entry.name, 'manifest.json'); + if (!fs.existsSync(candidate)) continue; + try { + const manifest = readManifest(candidate); + if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; + } catch { /* skip */ } + } + return null; +} + +export function readManifest(manifestPath) { + const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + return { + ...data, + manifestPath, + }; +} + +export function resolveSourceFile(sourceFile, cwd = process.cwd()) { + if (!sourceFile || path.isAbsolute(sourceFile)) { + throw new Error('Invalid svelte-component source file'); + } + const full = path.resolve(cwd, sourceFile); + const rel = path.relative(cwd, full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('Svelte-component source file escapes project root'); + } + if (!fs.existsSync(full)) { + throw new Error('Svelte-component source file not found: ' + sourceFile); + } + return full; +} + +function appendCssToSvelteStyle(lines, cssLines) { + const closeIdx = findLastStyleCloseLine(lines); + const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; + if (closeIdx === -1) { + return [...lines, '', '']; + } + return [ + ...lines.slice(0, closeIdx), + ...prepared, + ...lines.slice(closeIdx), + ]; +} + +function findLastStyleCloseLine(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + if (/<\/style\s*>/.test(lines[i])) return i; + } + return -1; +} + +function bakeParamValuesInCss(cssLines, paramValues) { + if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; + return cssLines.map((line) => { + let out = line; + for (const [key, value] of Object.entries(paramValues)) { + const varName = `--p-${key}`; + out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); + } + return out; + }); +} + +function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { + const css = String((cssLines || []).join('\n')); + if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; + + const rules = parseCssRules(css); + const output = []; + for (const rule of rules) { + appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); + } + return output.join('\n') + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ''); +} + +function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { + const prelude = rule.prelude.trim(); + const body = rule.body.trim(); + if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; + + if (/^@scope\b/i.test(prelude)) { + if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; + const inner = parseCssRules(body); + for (const innerRule of inner) { + const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); + if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; + output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); + } + return; + } + + const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); + if (!rewrittenPrelude) return; + output.push(formatCssRule(rewrittenPrelude, body)); +} + +function parseCssRules(css) { + const rules = []; + const text = String(css || ''); + let i = 0; + while (i < text.length) { + while (i < text.length && /\s/.test(text[i])) i++; + const preludeStart = i; + while (i < text.length && text[i] !== '{') i++; + if (i >= text.length) break; + const prelude = text.slice(preludeStart, i).trim(); + i++; + const bodyStart = i; + let depth = 1; + let quote = null; + let comment = false; + while (i < text.length && depth > 0) { + const ch = text[i]; + const next = text[i + 1]; + if (comment) { + if (ch === '*' && next === '/') { + comment = false; + i += 2; + continue; + } + i++; + continue; + } + if (quote) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === quote) quote = null; + i++; + continue; + } + if (ch === '/' && next === '*') { + comment = true; + i += 2; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); + if (prelude) rules.push({ prelude, body }); + } + return rules; +} + +function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { + const selectors = splitSelectorList(prelude); + const rewritten = []; + for (const selector of selectors) { + const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); + if (next) rewritten.push(next); + } + return rewritten.join(', '); +} + +function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { + let out = selector.trim(); + const hasVariant = /data-impeccable-variant/.test(out); + if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; + if (hasVariant) { + out = out.replace(variantSelectorRegex(variantNum), ''); + out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); + } + + const paramResult = rewriteParamSelectors(out, paramValues); + if (!paramResult.keep) return ''; + out = paramResult.selector; + + out = out + .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') + .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') + .replace(/\s+/g, ' ') + .trim(); + + out = out.replace(/^[>+~]\s*/, '').trim(); + if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; + return out; +} + +function rewriteParamSelectors(selector, paramValues) { + let keep = true; + const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { + if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; + const actual = paramValues[key]; + if (expected != null && String(actual) !== String(expected)) { + keep = false; + return ''; + } + if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { + keep = false; + return ''; + } + return ''; + }); + return { keep, selector: next }; +} + +function splitSelectorList(prelude) { + const selectors = []; + let start = 0; + let bracket = 0; + let paren = 0; + let quote = null; + for (let i = 0; i < prelude.length; i++) { + const ch = prelude[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '[') bracket++; + else if (ch === ']') bracket = Math.max(0, bracket - 1); + else if (ch === '(') paren++; + else if (ch === ')') paren = Math.max(0, paren - 1); + else if (ch === ',' && bracket === 0 && paren === 0) { + selectors.push(prelude.slice(start, i)); + start = i + 1; + } + } + selectors.push(prelude.slice(start)); + return selectors; +} + +function selectorHasVariant(selector, variantNum) { + return variantSelectorRegex(variantNum).test(selector); +} + +function variantSelectorRegex(variantNum) { + return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); +} + +function formatCssRule(selector, body) { + return `${selector} { ${body.trim()} }`; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { + const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); + const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); + const resultBase = { + file: manifest.sourceFile, + sourceFile: manifest.sourceFile, + previewMode: 'svelte-component', + componentDir: manifest.componentDir, + carbonize: false, + }; + if (!fs.existsSync(variantPath)) { + return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; + } + + const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); + if (manifest.mode === 'insert') { + return inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, + }); + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const contract = manifest.propContract || []; + const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); + const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract) + .split('\n') + .map((line) => line.trimEnd()); + + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const start = Number(manifest.sourceStartLine) - 1; + const end = Number(manifest.sourceEndLine) - 1; + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { + return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; + } + + const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, start), + ...indentedMarkup, + ...sourceLines.slice(end + 1), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, +}) { + if (!svelteMarkupHasVisibleContent(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; + } + if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const restoredMarkup = String(markup || '') + .split('\n') + .map((line) => line.trimEnd()); + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const insertIndex = Number(manifest.insertLine) - 1; + if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { + return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; + } + + const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; + const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, insertIndex), + ...indentedMarkup, + ...sourceLines.slice(insertIndex), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function svelteMarkupHasVisibleContent(markup) { + const text = String(markup || '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 0) return true; + return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); +} + +function mergeOriginalTopLevelAttrs(markup, originalMarkup) { + const variantOpen = matchOpeningTag(markup); + const originalOpen = matchOpeningTag(originalMarkup); + if (!variantOpen || !originalOpen) return markup; + if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; + + const variantAttrs = parseAttrSegments(variantOpen.attrs); + const originalAttrs = parseAttrSegments(originalOpen.attrs); + const additions = []; + let attrs = variantOpen.attrs; + + const originalClass = originalAttrs.get('class'); + const variantClass = variantAttrs.get('class'); + if (originalClass && variantClass) { + const merged = mergeStaticClassAttr(originalClass, variantClass); + if (merged) { + attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); + variantAttrs.set('class', { ...variantClass, raw: merged }); + } + } else if (originalClass && !variantClass) { + additions.push(originalClass.raw); + } + + for (const [name, attr] of originalAttrs) { + if (name === 'class') continue; + if (!variantAttrs.has(name)) additions.push(attr.raw); + } + + if (additions.length === 0 && attrs === variantOpen.attrs) return markup; + const nextOpen = variantOpen.prefix + + variantOpen.tag + + attrs + + additions.map((attr) => ' ' + attr.trim()).join('') + + variantOpen.close; + return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); +} + +function matchOpeningTag(markup) { + const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); + if (!match) return null; + return { + raw: match[0], + prefix: match[1], + tag: match[2], + attrs: match[3] || '', + close: match[4], + index: match.index || 0, + }; +} + +function parseAttrSegments(attrs) { + const out = new Map(); + const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; + let match; + while ((match = re.exec(attrs))) { + const raw = match[0]; + const name = match[1]; + out.set(name, { + name, + raw, + start: match.index, + end: match.index + raw.length, + }); + } + return out; +} + +function mergeStaticClassAttr(originalClass, variantClass) { + const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + if (!originalValue || !variantValue) return null; + const quote = variantValue[1]; + const classes = [ + ...variantValue[2].split(/\s+/), + ...originalValue[2].split(/\s+/), + ].filter(Boolean); + return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; +} + +export function removeSvelteComponentSession(id, cwd = process.cwd()) { + const dir = componentSessionDir(id, cwd); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* non-fatal */ } +} + +export function removeAllSvelteComponentSessions(cwd = process.cwd()) { + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('__')) continue; + try { + fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + } catch { /* non-fatal */ } + } +} + +export function deferredAcceptsPath(cwd = process.cwd()) { + const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16); + return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json'); +} + +export function readDeferredAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch { + return { accepts: [] }; + } +} + +export function writeDeferredAccept(entry, cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const data = readDeferredAccepts(cwd); + data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id); + data.accepts.push({ ...entry, createdAt: new Date().toISOString() }); + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + const data = readDeferredAccepts(cwd); + const pending = Array.isArray(data.accepts) ? data.accepts : []; + const results = []; + const remaining = []; + for (const entry of pending) { + try { + const manifest = findSvelteComponentManifest(entry.id, cwd); + if (!manifest) { + results.push({ id: entry.id, ok: false, error: 'manifest not found' }); + remaining.push(entry); + continue; + } + const result = inlineSvelteComponentAccept( + manifest, + entry.variantNum, + entry.paramValues || null, + cwd, + ); + results.push({ id: entry.id, ok: result.handled !== false, result }); + if (result.handled === false) remaining.push(entry); + } catch (err) { + results.push({ id: entry.id, ok: false, error: err.message }); + remaining.push(entry); + } + } + if (remaining.length > 0) { + fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8'); + } else { + try { fs.rmSync(file, { force: true }); } catch {} + } + return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results }; +} + +export function buildSvelteComponentCssAuthoring(count) { + const variantNumbers = Array.from({ length: count }, (_, i) => i + 1); + return { + mode: 'svelte-component', + styleTag: null, + strategy: 'component-style-block', + rulePattern: '.semantic-class { ... }', + selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'), + requirements: [ + 'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).', + 'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.', + 'Put variant CSS in the component ' : '')); + if (paramValues && Object.keys(paramValues).length > 0) { + lines.push( + bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close, + ); + } + lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + lines.push(bodyIndent + '
'); + lines.push(...bodyRestored); + lines.push(bodyIndent + '
'); + }; + + if (isJsx) { + const wrapperStyle = 'style={{ display: "contents" }}'; + lines.push(indent + '
'); + pushCarbonizeBody(indent + ' '); + lines.push(indent + '
'); + } else { + pushCarbonizeBody(indent); + } + + return lines; +} + +function reindentContent(contentLines, fromIndent, toIndent) { + return contentLines.map((line) => { + if (line.trim() === '') return ''; + if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length); + return toIndent + line.trimStart(); + }); +} + function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; @@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const needsCarbonize = !!(cssContent || hasHelperAttrs); - // Build the replacement const restored = deindentContent(variantContent, indent); - const replacement = []; - - if (cssContent) { - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); - // JSX targets need the CSS body wrapped in a template literal so that the - // `{` and `}` in CSS rules don't get parsed as JSX expressions. - replacement.push(indent + '' : '')); - if (paramValues && Object.keys(paramValues).length > 0) { - // Preserve the user's knob positions for the carbonize-cleanup agent - // to bake into the final CSS when it collapses scoped rules. - replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); - } - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); - } - - // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the - // carbonize CSS block working visually by re-wrapping the accepted content - // in a data-impeccable-variant="N" div with `display: contents` (so layout - // isn't affected). The carbonize agent strips this attribute + wrapper when - // it moves the CSS to a proper stylesheet. - // - // Style attribute syntax has to follow the host file's flavor — JSX files - // need the object form, otherwise React 19 throws "Failed to set indexed - // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. - if (cssContent) { - const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; - replacement.push(indent + '
'); - replacement.push(...restored); - replacement.push(indent + '
'); - } else { - replacement.push(...restored); - } + const replacement = buildCarbonizeReplacement({ + indent, + commentSyntax, + isJsx, + id, + variantNum, + cssContent, + paramValues, + restored, + }); const newLines = [ ...lines.slice(0, replaceRange.start), @@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; } +function readSourceShadowPreviewMeta(content, id) { + const escaped = escapeRegExp(id); + const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>'); + const match = String(content || '').match(wrapperRe); + if (!match) return null; + const tag = match[0]; + if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null; + const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file'); + const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start')); + const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end')); + if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null; + return { sourceFile, sourceStartLine, sourceEndLine }; +} + +function readHtmlAttr(tag, name) { + const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1')); + if (!match) return null; + return decodeHtmlAttr(match[2]); +} + +function decodeHtmlAttr(value) { + return String(value || '') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- @@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts }; diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index f609ac847..d7c580e2f 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -26,9 +26,9 @@ return; } - // --------------------------------------------------------------------------- + // // Design tokens - // --------------------------------------------------------------------------- + // // Brand kinpaku (gold) is pinned to the site's neo-kinpaku tokens // (see site/styles/kinpaku-tokens.css) so Accept / knobs / cycle-dots / @@ -115,19 +115,48 @@ { value: 'overdrive', label: 'Overdrive' }, ]; - // --------------------------------------------------------------------------- + const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions']; + const LIVE_UI_SURFACES = [ + { 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'] }, + { key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] }, + { key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-configure-input-wrap', PREFIX + '-input', PREFIX + '-configure-voice'] }, + { key: 'action-picker', ids: [PREFIX + '-picker'] }, + { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, + { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, + { key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] }, + { key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] }, + { key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] }, + { 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'] }, + { key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] }, + { key: 'design-system-panel', ids: [PREFIX + '-design-host'] }, + { key: 'toasts-and-errors', ids: [PREFIX + '-toast'] }, + { key: 'css-isolation-boundary', ids: [PREFIX + '-root'] }, + ]; + const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))]; + + // // State - // --------------------------------------------------------------------------- + // let state = 'IDLE'; let hoveredElement = null; let selectedElement = null; let currentSessionId = null; - let pendingAcceptedSession = null; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; + let svelteComponentSession = null; + let svelteRuntimePromise = null; + let pendingSvelteComponentRetryObserver = null; + let currentSourceFile = null; + let currentPreviewFile = null; + let currentPreviewMode = null; + let recoveryWaitingForAnchor = false; + let pendingAcceptedSession = null; let variantObserver = null; + let variantSelectionInFlight = false; + let variantSelectionPromise = null; + let recoveringEmptyCycling = false; let hasProjectContext = false; let selectedAction = 'impeccable'; let selectedCount = 3; @@ -175,14 +204,17 @@ let highlightEl = null; let tooltipEl = null; let barEl = null; + let barHideSeq = 0; let pickerEl = null; let toastEl = null; let scrollRaf = null; let editBadgeEl = null; + let editBadgeProxyRoot = null; + let editBadgeProxyByTarget = new Map(); - // --------------------------------------------------------------------------- + // // Helpers - // --------------------------------------------------------------------------- + // function own(el) { return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); @@ -204,8 +236,105 @@ return s; } + function rectIsUsableAnchor(rect) { + return !!rect && rect.width > 0.5 && rect.height > 0.5; + } + + function makeFrozenAnchor(el) { + if (!el || !el.getBoundingClientRect) return null; + const r = el.getBoundingClientRect(); + if (!rectIsUsableAnchor(r)) return null; + const rect = { + x: r.x, y: r.y, + top: r.top, left: r.left, + right: r.right, bottom: r.bottom, + width: r.width, height: r.height, + }; + return { + __impeccableFrozenAnchor: true, + tagName: el.tagName || 'DIV', + id: el.id || '', + classList: el.classList ? [...el.classList] : [], + hasAttribute: () => false, + getBoundingClientRect: () => rect, + }; + } + function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } + function cssId(id) { + if (window.CSS?.escape) return CSS.escape(id); + return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); + } + + function liveUiRoot() { + const root = window.__IMPECCABLE_LIVE_UI_ROOT__; + if (root && typeof root.appendChild === 'function') return root; + return document.body; + } + + function uiAppend(el) { + liveUiRoot().appendChild(el); + return el; + } + + function uiAppendStyle(styleEl) { + const root = liveUiRoot(); + if (root && root !== document.body) root.appendChild(styleEl); + else document.head.appendChild(styleEl); + return styleEl; + } + + function uiGetById(id) { + const root = liveUiRoot(); + if (root?.getElementById) { + const found = root.getElementById(id); + if (found) return found; + } + if (root?.querySelector) { + const found = root.querySelector('#' + cssId(id)); + if (found) return found; + } + return document.getElementById(id); + } + + function activeElementDeep() { + let active = document.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; + return active; + } + + window.__IMPECCABLE_LIVE_CHROME_CORE__ = { + version: 1, + adapter: window.__IMPECCABLE_LIVE_ADAPTER__ || 'dom', + mountContract: LIVE_CHROME_MOUNT_CONTRACT, + surfaces: LIVE_UI_SURFACES, + componentIds: LIVE_UI_COMPONENT_IDS, + root: liveUiRoot, + append: uiAppend, + appendStyle: uiAppendStyle, + getById: uiGetById, + activeElementDeep, + debugState: () => ({ + state, + currentSessionId, + expectedVariants, + arrivedVariants, + visibleVariant, + savedSession: loadSession(), + sourceFile: currentSourceFile, + previewFile: currentPreviewFile, + previewMode: currentPreviewMode, + barText: barEl?.textContent || null, + barConnected: !!barEl?.isConnected, + hasSvelteComponentSession: !!svelteComponentSession, + mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0, + pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver, + recoveryWaitingForAnchor, + evtSourceReadyState: evtSource ? evtSource.readyState : null, + }), + }; + // Modal-aware chrome: keep our floating UI clickable inside Radix / // Headless UI / vaul portals. // @@ -245,9 +374,9 @@ rootEl.addEventListener('focusin', stop); } - // --------------------------------------------------------------------------- + // // Highlight overlay - // --------------------------------------------------------------------------- + // function initHighlight() { highlightEl = document.createElement('div'); @@ -259,7 +388,7 @@ transition: HIGHLIGHT_TRANSITION, display: 'none', opacity: '0', }); - document.body.appendChild(highlightEl); + uiAppend(highlightEl); tooltipEl = document.createElement('div'); tooltipEl.id = PREFIX + '-tooltip'; @@ -273,7 +402,7 @@ letterSpacing: '0.02em', transition: TOOLTIP_TRANSITION, }); - document.body.appendChild(tooltipEl); + uiAppend(tooltipEl); } function showHighlight(el) { @@ -310,7 +439,7 @@ if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; } } - // --------------------------------------------------------------------------- + // // Annotation overlay (comment pins + kinpaku strokes) // // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned @@ -318,7 +447,7 @@ // drag) drops a comment pin; drag paints a kinpaku SVG stroke. All coords // are stored in element-local CSS px so they survive scroll / resize and // correlate directly with the captured PNG. - // --------------------------------------------------------------------------- + // const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it @@ -397,7 +526,7 @@ annotOverlayEl.addEventListener('pointermove', onAnnotMove); annotOverlayEl.addEventListener('pointerup', onAnnotUp); annotOverlayEl.addEventListener('pointercancel', onAnnotUp); - document.body.appendChild(annotOverlayEl); + uiAppend(annotOverlayEl); // Modal-host friendliness: pointer-events is already 'auto' on this // overlay; we only need to silence the host's outside-interaction // listeners. Don't override pointer-events here (the overlay toggles @@ -828,9 +957,9 @@ return wrap; } - // --------------------------------------------------------------------------- + // // Element context extraction - // --------------------------------------------------------------------------- + // function stripManualEditRuntimeState(root) { if (!root || root.nodeType !== 1) return; @@ -971,9 +1100,9 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } - // --------------------------------------------------------------------------- + // // The Bar - one floating element, three modes - // --------------------------------------------------------------------------- + // // Contextual-bar palette. Cached at init so every build*Row reads a // consistent set of colors; detectPageTheme runs once rather than on every @@ -1006,7 +1135,7 @@ padding: '6px', maxWidth: '520px', minWidth: '320px', }); - document.body.appendChild(barEl); + uiAppend(barEl); defangOutsideHandlers(barEl); } @@ -1041,6 +1170,8 @@ } function showBar(mode) { + barHideSeq += 1; + if (mode === 'cycling' && !ensureCyclingRenderable('show-bar')) return; barEl.innerHTML = ''; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); @@ -1058,11 +1189,12 @@ function hideBar() { if (!barEl) return; + const hideSeq = ++barHideSeq; stopVoice({ suppressSubmit: true }); if (configureKind === 'insert') clearInsertPicking(); barEl.style.opacity = '0'; barEl.style.transform = 'translateY(6px)'; - setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); + setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); if (state === 'EDITING') restoreInlineEditDrafts(); @@ -1071,6 +1203,7 @@ function updateBarContent(mode) { if (!barEl || barEl.style.display === 'none') return; + if (mode === 'cycling' && !ensureCyclingRenderable('update-bar')) return; barEl.innerHTML = ''; // Reset bar styling to the kinpaku picker palette barEl.style.background = BP.surface; @@ -1090,13 +1223,13 @@ syncPageChatFocus('update-bar-content'); } - // --- Configure row --- + // Configure row function syncConfigureInputChrome() { - const wrap = document.getElementById(PREFIX + '-configure-input-wrap'); - const input = document.getElementById(PREFIX + '-input'); + const wrap = uiGetById(PREFIX + '-configure-input-wrap'); + const input = uiGetById(PREFIX + '-input'); if (!wrap || !input) return; - const focused = document.activeElement === input; + const focused = activeElementDeep() === input; wrap.dataset.inputFocused = focused ? 'true' : 'false'; wrap.dataset.voiceListening = (voiceListening && voiceCtx?.mode === 'configure') ? 'true' : 'false'; wrap.style.borderColor = (voiceListening && voiceCtx?.mode === 'configure') @@ -1104,7 +1237,7 @@ : (focused ? BP.accentSoft : BP.hairline); } - // --- Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) --- + // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) function detectInsertAxisFromStyle(style) { const display = style?.display || 'block'; @@ -1377,7 +1510,7 @@ display: 'none', opacity: '0.9', }); - document.body.appendChild(insertLineEl); + uiAppend(insertLineEl); defangOutsideHandlers(insertLineEl); return insertLineEl; } @@ -1440,6 +1573,10 @@ /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { + if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor) return anchor; + } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (wrapper) { @@ -1557,6 +1694,11 @@ positionBar(); } + function showOrUpdateCyclingBar() { + if (barEl && barEl.style.display !== 'none') updateBarContent('cycling'); + else showBar('cycling'); + } + function buildPlaceholderResizeHandles() { if (!placeholderResizeLayerEl) return; placeholderResizeLayerEl.innerHTML = ''; @@ -1665,7 +1807,7 @@ } function isInsertCreateEnabled(btn) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); + btn = btn || uiGetById(PREFIX + '-insert-create'); return !!btn && btn.getAttribute('aria-disabled') !== 'true'; } @@ -1691,7 +1833,7 @@ lineHeight: '1.35', }); insertCreateTooltipEl.id = PREFIX + '-insert-create-tooltip'; - document.body.appendChild(insertCreateTooltipEl); + uiAppend(insertCreateTooltipEl); return insertCreateTooltipEl; } @@ -1723,8 +1865,8 @@ } function syncInsertCreateButton(btn, input) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); - input = input || document.getElementById(PREFIX + '-insert-input'); + btn = btn || uiGetById(PREFIX + '-insert-create'); + input = input || uiGetById(PREFIX + '-insert-input'); if (!btn || !input) return; const gate = insertCreateGateState(input); const ok = canCreateInsert(gate); @@ -1833,7 +1975,7 @@ voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!document.getElementById(PREFIX + '-configure-input-style')) { + if (!uiGetById(PREFIX + '-configure-input-style')) { const s = document.createElement('style'); s.id = PREFIX + '-configure-input-style'; s.textContent = @@ -1842,7 +1984,7 @@ '#' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: impeccable-configure-voice-pulse 1.1s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-configure-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } input.addEventListener('focus', () => syncConfigureInputChrome()); @@ -1949,6 +2091,9 @@ transition: 'border-color 0.15s ease', }); inputWrap.id = PREFIX + '-insert-input-wrap'; + inputWrap.addEventListener('pointerdown', (e) => e.stopPropagation()); + inputWrap.addEventListener('mousedown', (e) => e.stopPropagation()); + inputWrap.addEventListener('click', (e) => e.stopPropagation()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; @@ -1984,6 +2129,12 @@ voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; input.addEventListener('input', () => syncInsertCreateButton()); + input.addEventListener('pointerdown', (e) => e.stopPropagation()); + input.addEventListener('mousedown', (e) => e.stopPropagation()); + input.addEventListener('click', (e) => { + e.stopPropagation(); + try { input.focus({ preventScroll: true }); } catch { input.focus(); } + }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); @@ -2049,6 +2200,7 @@ }); create.addEventListener('mouseleave', hideInsertCreateTooltip); create.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); if (controlsLocked) { showManualApplyBusyToast(); return; } if (!isInsertCreateEnabled(create)) return; @@ -2060,7 +2212,7 @@ return row; } - // --- Generating row --- + // Generating row function buildGeneratingRow() { const row = el('div', { @@ -2086,19 +2238,24 @@ }); // Variants currently arrive atomically in a single file edit, so a // per-variant counter would lie. Say what's true. - status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + expectedVariants + ' variants...' - : 'Done'; + status.textContent = recoveryWaitingForAnchor + ? 'Variants ready. Reveal the selected element to resume.' + : (arrivedVariants < expectedVariants + ? 'Generating ' + expectedVariants + ' variants...' + : 'Done'); row.appendChild(status); return row; } - // --- Cycling row --- + // Cycling row const TUNE_ICON_SVG = ''; function buildCyclingRow() { + if (!ensureCyclingRenderable('build-cycling-row')) { + return el('div', { display: 'none' }); + } const row = el('div', { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 2px', @@ -2106,6 +2263,7 @@ // Prev const prev = navBtn('\u2190'); + prev.id = PREFIX + '-variant-prev'; prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); }); if (visibleVariant <= 1) prev.style.opacity = '0.3'; row.appendChild(prev); @@ -2118,11 +2276,13 @@ fontFamily: MONO, fontSize: '11px', fontWeight: '500', color: BP.textDim, minWidth: '24px', textAlign: 'center', }); + counter.id = PREFIX + '-variant-counter'; counter.textContent = visibleVariant + '/' + arrivedVariants; row.appendChild(counter); // Next const next = navBtn('\u2192'); + next.id = PREFIX + '-variant-next'; next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); }); if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3'; row.appendChild(next); @@ -2208,9 +2368,9 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders - // --- Saving row (waiting for agent to process accept/discard) --- + // Saving row (waiting for agent to process accept/discard) function buildSavingRow() { const row = el('div', { @@ -2235,7 +2395,7 @@ return row; } - // --- Confirmed row (green success, auto-dismisses) --- + // Confirmed row (green success, auto-dismisses) function buildConfirmedRow() { const row = el('div', { @@ -2256,7 +2416,7 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders function buildDots(clickable) { const container = el('div', { @@ -2290,10 +2450,7 @@ const idx = i; dot.addEventListener('click', (e) => { e.stopPropagation(); - visibleVariant = idx; - showVariantInDOM(currentSessionId, idx); - updateSelectedElement(); - updateBarContent('cycling'); + selectVariant(idx, 'variant_changed'); }); } container.appendChild(dot); @@ -2323,13 +2480,14 @@ function el(tag, styles) { const e = document.createElement(tag); + if (String(tag).toLowerCase() === 'button') e.type = 'button'; if (styles) Object.assign(e.style, styles); return e; } - // --------------------------------------------------------------------------- + // // Action picker popover - // --------------------------------------------------------------------------- + // function initActionPicker() { const P = barPaletteForTheme(detectPageTheme()); @@ -2384,16 +2542,20 @@ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent'; }); chip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + const prompt = uiGetById(PREFIX + '-input')?.value || ''; selectedAction = action.value; hideActionPicker(); updateBarContent('configure'); + const input = uiGetById(PREFIX + '-input'); + if (input && prompt) input.value = prompt; }); grid.appendChild(chip); }); pickerEl.appendChild(grid); - document.body.appendChild(pickerEl); + uiAppend(pickerEl); defangOutsideHandlers(pickerEl); // Cache the palette on the picker so toggleActionPicker's state refresh @@ -2433,7 +2595,33 @@ setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180); } - // --------------------------------------------------------------------------- + function ensureCyclingRenderable(reason) { + if (arrivedVariants > 0) { + if (visibleVariant < 1 || visibleVariant > arrivedVariants) visibleVariant = 1; + return true; + } + recoverEmptyCycling(reason); + return false; + } + + function recoverEmptyCycling(reason) { + if (recoveringEmptyCycling) return; + recoveringEmptyCycling = true; + try { + console.warn('[impeccable] Refusing to render empty variant cycling state:', reason); + const message = 'No variants were mounted. Please try again.'; + if (svelteComponentSession?.sessionId === currentSessionId) { + abortSvelteComponentInjection(currentSessionId, message); + return; + } + cleanup(); + showToast(message, 5000); + } finally { + recoveringEmptyCycling = false; + } + } + + // // Params panel (per-variant coarse controls) // // Variants may declare a parameter manifest via a JSON attribute on the @@ -2446,13 +2634,13 @@ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped // CSS can respond instantly without regeneration: // - // range / numeric toggle → CSS var (`--p-`) used via var(--p-foo, N) + // range / numeric toggle -> CSS custom property used by variant styles // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."] // // On variant switch, values reset to that variant's declared defaults. // On accept, current values are sent in the event payload so the agent // can bake them into the source-file write. - // --------------------------------------------------------------------------- + // let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide) let paramsPanelInner = null; // translating content (carries bg, padding, knobs) @@ -2507,7 +2695,7 @@ }); paramsPanelEl.appendChild(paramsPanelBody); - document.body.appendChild(paramsPanelEl); + uiAppend(paramsPanelEl); // Don't override pointer-events: the panel toggles between 'none' (closed, // click-through) and 'auto' (open) on its own. Just silence the host's // outside-interaction listeners while the panel is open. @@ -2516,14 +2704,40 @@ } + function getMountedSvelteComponentAnchor(session = svelteComponentSession) { + const el = session?.mountTargetEl?.firstElementChild || null; + if (!el || !document.body.contains(el)) return null; + return rectIsUsableAnchor(el.getBoundingClientRect()) ? el : null; + } + + function resolveSvelteComponentAnchor(session = svelteComponentSession) { + return getMountedSvelteComponentAnchor(session) + || session?.swapAnchor + || null; + } + function getVisibleVariantEl() { if (!currentSessionId) return null; + if (svelteComponentSession?.sessionId === currentSessionId) { + return resolveSvelteComponentAnchor() + || svelteComponentSession.wrapperEl + || null; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } function parseVariantParams(variantEl) { + // Svelte component variants can't carry a `data-impeccable-params` attribute: + // the compiler reads `{` inside attribute values as expression delimiters, so + // JSON-with-braces breaks the build. For that path the params live in a sidecar + // params.json keyed by variant number, loaded into the session at mount time. + if (svelteComponentSession?.sessionId === currentSessionId) { + const byVariant = svelteComponentSession.paramsByVariant || {}; + const params = byVariant[String(visibleVariant)] || byVariant[visibleVariant]; + return Array.isArray(params) ? params : []; + } if (!variantEl) return []; const raw = variantEl.getAttribute('data-impeccable-params'); if (!raw) return []; @@ -2685,11 +2899,11 @@ } } - // --------------------------------------------------------------------------- + // // Inline text editing - makes pure-text descendants of the picked element // directly contenteditable. Save stages copy edits in the live buffer; the // Apply copy edits dock later asks the AI to apply the staged batch. - // --------------------------------------------------------------------------- + // let inlineEditRows = []; let inlineEditDrafts = new Map(); @@ -2803,7 +3017,7 @@ function disableInlineEdit(opts = {}) { for (const row of inlineEditRows) { - if (document.activeElement === row.el) row.el.blur(); + if (activeElementDeep() === row.el) row.el.blur(); row.el.removeAttribute('contenteditable'); delete row.el.dataset.impeccableEditable; delete row.el.dataset.impeccableOriginalText; @@ -3133,7 +3347,7 @@ if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) { showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); } else { - showToast('Save failed: retry or cancel', 4000); + showToast('Save failed - retry or cancel', 4000); } } } @@ -3181,11 +3395,11 @@ } function ensureSpinKeyframes() { - if (document.getElementById(PREFIX + '-keyframes')) return; + if (uiGetById(PREFIX + '-keyframes')) return; const style = document.createElement('style'); style.id = PREFIX + '-keyframes'; style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }'; - document.head.appendChild(style); + uiAppendStyle(style); } function pendingApplyLabel(count) { @@ -3318,10 +3532,10 @@ closeTunePopover(); } if (barEl && barEl.style.display !== 'none' && state === 'CONFIGURING') { - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value : ''; updateBarContent('configure'); - const nextInput = document.getElementById(PREFIX + '-input'); + const nextInput = uiGetById(PREFIX + '-input'); if (nextInput) nextInput.value = prompt; } if (editBadgeEl && editBadgeEl.style.display !== 'none') { @@ -3455,19 +3669,19 @@ updatePendingCounter(remaining); if (result.failed && result.failed.length > 0) { console.warn('[impeccable] some copy edits failed:', result.failed); - showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed - see console', 5000); } else { const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0); if (n > 0) { showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); } else { console.warn('[impeccable] apply returned no verified edits:', result); - showToast('No edits applied, see console', 4000); + showToast('No edits applied - see console', 4000); } } } catch (err) { console.error('[impeccable] commit failed:', err); - showToast('Apply failed, see console', 4000); + showToast('Apply failed - see console', 4000); } finally { if (waitForSseCompletion) return; const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0; @@ -3497,7 +3711,7 @@ } } catch (err) { console.error('[impeccable] discard failed:', err); - showToast('Discard failed, see console', 4000); + showToast('Discard failed - see console', 4000); } } @@ -3645,7 +3859,7 @@ const failedCount = numberOrNull(msg.failedCount) || 0; const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0; if (failedCount > 0) { - showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000); + showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed - see console', 5000); } else if (appliedCount > 0) { showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500); } @@ -3799,9 +4013,164 @@ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); } - // --------------------------------------------------------------------------- + // // Edit content badge - floating button at element top-right to enter EDITING mode - // --------------------------------------------------------------------------- + // + + function usesShadowChromeRoot() { + const root = liveUiRoot(); + return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; + } + + function setImportantStyle(el, name, value) { + el.style.setProperty(name, value, 'important'); + } + + function initEditBadgeHitProxies() { + if (!usesShadowChromeRoot() || editBadgeProxyRoot) return; + editBadgeProxyRoot = document.createElement('div'); + editBadgeProxyRoot.id = PREFIX + '-edit-badge-hit-proxies'; + editBadgeProxyRoot.setAttribute('aria-hidden', 'true'); + const styles = { + all: 'initial', + position: 'fixed', + inset: '0', + width: '100vw', + height: '100vh', + zIndex: String(Z.toast + 1), + pointerEvents: 'none', + background: 'transparent', + overflow: 'visible', + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + document.body.appendChild(editBadgeProxyRoot); + } + + function styleEditBadgeProxy(proxy, target) { + const rect = target.getBoundingClientRect(); + const cursor = getComputedStyle(target).cursor || 'pointer'; + const styles = { + all: 'initial', + position: 'fixed', + left: rect.left + 'px', + top: rect.top + 'px', + width: rect.width + 'px', + height: rect.height + 'px', + margin: '0', + padding: '0', + border: '0', + borderRadius: '0', + background: 'transparent', + color: 'transparent', + opacity: '0.001', + pointerEvents: 'auto', + cursor, + zIndex: String(Z.toast + 2), + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(proxy, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + } + + function proxyMouseEvent(type, source, target) { + let event; + try { + event = new MouseEvent(type, { + bubbles: type !== 'mouseenter' && type !== 'mouseleave', + cancelable: true, + composed: true, + clientX: source.clientX, + clientY: source.clientY, + screenX: source.screenX, + screenY: source.screenY, + button: source.button || 0, + buttons: source.buttons || 0, + ctrlKey: source.ctrlKey, + metaKey: source.metaKey, + shiftKey: source.shiftKey, + altKey: source.altKey, + }); + target.dispatchEvent(event); + } catch {} + } + + function bindEditBadgeProxy(proxy, target) { + const stop = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + proxy.addEventListener('mouseenter', (event) => { + stop(event); + proxyMouseEvent('mouseenter', event, target); + proxyMouseEvent('mouseover', event, target); + }); + proxy.addEventListener('mouseleave', (event) => { + stop(event); + proxyMouseEvent('mouseleave', event, target); + proxyMouseEvent('mouseout', event, target); + }); + proxy.addEventListener('mousedown', (event) => { + stop(event); + target.focus?.({ preventScroll: true }); + proxyMouseEvent('mousedown', event, target); + }); + proxy.addEventListener('mouseup', (event) => { + stop(event); + proxyMouseEvent('mouseup', event, target); + }); + proxy.addEventListener('click', (event) => { + stop(event); + target.click(); + syncEditBadgeHitProxies(); + }); + } + + function editBadgeProxyTargets() { + if (!usesShadowChromeRoot() || !editBadgeEl || editBadgeEl.style.display === 'none') return []; + return [...editBadgeEl.querySelectorAll('button')].filter((target) => { + if (target.disabled) return false; + const rect = target.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return false; + const style = getComputedStyle(target); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); + } + + function syncEditBadgeHitProxies() { + if (!usesShadowChromeRoot()) { + if (editBadgeProxyRoot) editBadgeProxyRoot.remove(); + editBadgeProxyRoot = null; + editBadgeProxyByTarget = new Map(); + return; + } + initEditBadgeHitProxies(); + if (!editBadgeProxyRoot) return; + const targets = editBadgeProxyTargets(); + const active = new Set(targets); + for (const [target, proxy] of editBadgeProxyByTarget) { + if (!active.has(target) || !target.isConnected) { + proxy.remove(); + editBadgeProxyByTarget.delete(target); + } + } + for (const target of targets) { + let proxy = editBadgeProxyByTarget.get(target); + if (!proxy) { + proxy = document.createElement('button'); + proxy.type = 'button'; + proxy.tabIndex = -1; + proxy.dataset.impeccableEditBadgeProxy = 'true'; + proxy.setAttribute('aria-hidden', 'true'); + bindEditBadgeProxy(proxy, target); + editBadgeProxyRoot.appendChild(proxy); + editBadgeProxyByTarget.set(target, proxy); + } + proxy.title = target.title || target.textContent || 'Edit copy'; + styleEditBadgeProxy(proxy, target); + } + } function initEditBadge() { editBadgeEl = document.createElement('div'); @@ -3813,10 +4182,11 @@ display: 'none', userSelect: 'none', }); - document.body.appendChild(editBadgeEl); + uiAppend(editBadgeEl); + initEditBadgeHitProxies(); // Remove focus rings on edit badge buttons + contenteditable elements - if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + if (!uiGetById(PREFIX + '-edit-badge-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-edit-badge-focus-style'; s.textContent = @@ -3826,21 +4196,26 @@ '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; - document.head.appendChild(s); + uiAppendStyle(s); } } function positionEditBadge() { - if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') { + syncEditBadgeHitProxies(); + return; + } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { if (editBadgeEl) editBadgeEl.style.display = 'none'; + syncEditBadgeHitProxies(); return; } editBadgeEl.style.display = 'flex'; @@ -4047,7 +4422,7 @@ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN; } // Re-render the bar so the Tune chip picks up the active styling. - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } function closeTunePopover() { @@ -4055,13 +4430,13 @@ hideParamsPanel(); if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT; if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') { - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } } - // --------------------------------------------------------------------------- + // // Variant cycling in DOM - // --------------------------------------------------------------------------- + // function isVariantShown(el) { if (!el) return false; @@ -4081,9 +4456,42 @@ } } - function showVariantInDOM(sessionId, num) { + function scheduleCyclingBarSync(sessionId, variantNum) { + requestAnimationFrame(() => { + if (state !== 'CYCLING') return; + if (currentSessionId !== sessionId) return; + if (visibleVariant !== variantNum) return; + showOrUpdateCyclingBar(); + syncCyclingControls(); + positionBar(); + }); + } + + function syncCyclingControls() { + const shown = svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0 + ? svelteComponentSession.mountedVariant + : visibleVariant; + const counter = uiGetById(PREFIX + '-variant-counter'); + if (counter && arrivedVariants > 0) counter.textContent = shown + '/' + arrivedVariants; + const prev = uiGetById(PREFIX + '-variant-prev'); + const next = uiGetById(PREFIX + '-variant-next'); + if (prev) prev.style.opacity = shown <= 1 ? '0.3' : '1'; + if (next) next.style.opacity = shown >= arrivedVariants ? '0.3' : '1'; + if (currentSessionId && state === 'CYCLING') saveSession(); + } + + async function showVariantInDOM(sessionId, num) { + if (svelteComponentSession?.sessionId === sessionId) { + visibleVariant = num; + const mounted = await mountSvelteComponentVariant(num); + if (!mounted) return false; + updateSelectedElement(); + refreshParamsPanel(); + scheduleCyclingBarSync(sessionId, num); + return true; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); - if (!wrapper) return; + if (!wrapper) return false; for (const child of wrapper.children) { const v = child.dataset ? child.dataset.impeccableVariant : null; if (!v) continue; @@ -4093,6 +4501,378 @@ // CYCLING yet, the subsequent CYCLING transition triggers its own // refresh) and every cycle step. refreshParamsPanel(); + return true; + } + + function isSvelteComponentManifestPath(filePath) { + return String(filePath || '').endsWith('manifest.json'); + } + + function parseOriginalMarkupElement(originalMarkup) { + const parser = new DOMParser(); + const doc = parser.parseFromString('
' + originalMarkup + '
', 'text/html'); + return doc.getElementById('impeccable-anchor')?.firstElementChild || null; + } + + function findLiveElementForOriginalMarkup(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const tag = origContent.tagName.toLowerCase(); + const cls = origContent.className; + let liveEl = null; + if (origContent.id) { + liveEl = document.getElementById(origContent.id); + } else if (cls) { + const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + for (const c of candidates) { + if (c.className === cls && !own(c)) { liveEl = c; break; } + } + if (!liveEl) { + const expectedClasses = String(cls).split(/\s+/).filter(Boolean); + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } + } + } + return liveEl; + } + + function isSvelteInsertManifest(manifest) { + return manifest?.previewMode === 'svelte-component' && manifest?.mode === 'insert'; + } + + function findLiveElementForSvelteManifest(manifest) { + if (isSvelteInsertManifest(manifest)) { + const anchor = findInsertAnchorInDom(); + if (anchor?.parentElement) return anchor; + } + return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function loadSvelteRuntime(runtimeModule) { + const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js'; + const url = new URL(modulePath, location.origin).href; + if (!svelteRuntimePromise) { + svelteRuntimePromise = import(/* @vite-ignore */ url); + } + return svelteRuntimePromise; + } + + // Svelte component variants declare their params in a sidecar params.json under + // componentDir (keyed by variant number), because a `data-impeccable-params` + // attribute with JSON braces can't survive the Svelte compiler. Returns a map of + // { "1": [...params], "2": [...] }; an empty object when the agent declared none. + async function loadSvelteComponentParams(manifest) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir) return {}; + const paramsPath = dir + '/params.json'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(paramsPath); + try { + const res = await fetch(url); + if (!res.ok) return {}; + const parsed = JSON.parse(await res.text()); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out = {}; + for (const [key, value] of Object.entries(parsed)) { + if (Array.isArray(value)) out[String(key)] = value; + } + return out; + } catch { + return {}; + } + } + + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { + const contract = manifest?.propContract || []; + const values = {}; + if (!liveEl || contract.length === 0) return values; + const sourceOriginal = parseOriginalMarkupElement(manifest.originalMarkup || ''); + if (!sourceOriginal) return values; + const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl); + for (const entry of contract) { + const token = '{' + entry.expr + '}'; + values[entry.prop] = map.get(token) || ''; + } + return values; + } + + async function mountSvelteComponentVariant(variantNum) { + if (!svelteComponentSession || !variantNum) return false; + const { manifest, mountTargetEl, sessionId } = svelteComponentSession; + try { + const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement; + svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null; + const runtime = await loadSvelteRuntime(manifest.runtimeModule); + const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte'; + const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now(); + const mod = await import(/* @vite-ignore */ moduleUrl); + const Component = mod.default; + if (svelteComponentSession.mountedInstance && runtime.unmount) { + await runtime.unmount(svelteComponentSession.mountedInstance); + svelteComponentSession.mountedInstance = null; + } + svelteComponentSession.mountedInstance = runtime.mount(Component, { + target: mountTargetEl, + props: { ...svelteComponentSession.propValues }, + intro: false, + }); + svelteComponentSession.mountedVariant = variantNum; + svelteComponentSession.runtime = runtime; + if (state === 'CYCLING') syncCyclingControls(); + const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (nextAnchor) { + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(nextAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = nextAnchor; + } else { + requestAnimationFrame(() => { + if (svelteComponentSession?.sessionId !== sessionId) return; + const settledAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!settledAnchor) return; + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(settledAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = settledAnchor; + }); + } + return true; + } catch (err) { + if (svelteComponentSession?.sessionId === sessionId) { + svelteComponentSession.swapAnchor = null; + } + console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err); + return false; + } + } + + function teardownSvelteComponentSession(restoreOriginal) { + if (!svelteComponentSession) return; + const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + if (restoreOriginal && detachedOriginal && wrapperEl?.parentElement) { + wrapperEl.parentElement.replaceChild(detachedOriginal, wrapperEl); + } else if (wrapperEl?.parentElement) { + wrapperEl.remove(); + } + svelteComponentSession = null; + svelteRuntimePromise = null; + } + + function applyOriginalAttrsToSvelteAnchor(el, originalMarkup) { + if (!el || !originalMarkup) return; + const original = parseOriginalMarkupElement(originalMarkup); + if (!original || original.tagName !== el.tagName) return; + for (const attr of original.attributes) { + if (attr.name === 'class') { + for (const className of attr.value.split(/\s+/).filter(Boolean)) { + el.classList.add(className); + } + } else if (!el.hasAttribute(attr.name)) { + el.setAttribute(attr.name, attr.value); + } + } + } + + function commitAcceptedSvelteComponentToDom(sessionId) { + if (!svelteComponentSession || svelteComponentSession.sessionId !== sessionId) return false; + const { wrapperEl, runtime, mountedInstance, manifest } = svelteComponentSession; + const anchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!anchor || !wrapperEl?.parentElement) return false; + const committed = anchor.cloneNode(true); + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(committed, manifest.originalMarkup || ''); + } + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + wrapperEl.parentElement.replaceChild(committed, wrapperEl); + svelteComponentSession = null; + svelteRuntimePromise = null; + selectedElement = committed; + return true; + } + + async function injectSvelteComponentsFromManifest(manifestPath, sessionId) { + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(manifestPath); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + const manifest = JSON.parse(await res.text()); + if (manifest.id !== sessionId) return; + + const paramsByVariant = await loadSvelteComponentParams(manifest); + currentSessionId = sessionId; + expectedVariants = Number(manifest.count) || expectedVariants || 1; + rememberSessionFileMeta({ + sourceFile: manifest.sourceFile, + previewFile: manifestPath, + previewMode: 'svelte-component', + }); + if (state !== 'CYCLING') state = 'GENERATING'; + + const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { + recoveryWaitingForAnchor = false; + svelteComponentSession.paramsByVariant = paramsByVariant; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; + await mountSvelteComponentVariant(visibleVariant || 1); + state = 'CYCLING'; + showOrUpdateCyclingBar(); + saveSession(); + return; + } + + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) { + console.warn('[impeccable] Could not find original element in live DOM.'); + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants + ? visibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + saveSession(); + queueCheckpoint('svelte_component_anchor_missing'); + waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return; + } + + const wrapper = document.createElement('div'); + wrapper.dataset.impeccableVariants = sessionId; + wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1); + wrapper.dataset.impeccablePreview = 'svelte-component'; + wrapper.style.display = 'contents'; + + const mountTarget = document.createElement('div'); + mountTarget.dataset.impeccableComponentMount = sessionId; + mountTarget.style.display = 'contents'; + wrapper.appendChild(mountTarget); + + const insertMode = isSvelteInsertManifest(manifest); + const detachedOriginal = insertMode ? null : liveEl; + if (insertMode) { + removeInsertPlaceholderDom(); + if (manifest.position === 'before') liveEl.parentElement.insertBefore(wrapper, liveEl); + else liveEl.parentElement.insertBefore(wrapper, liveEl.nextSibling); + } else { + liveEl.parentElement.replaceChild(wrapper, liveEl); + } + + svelteComponentSession = { + sessionId, + manifest, + insertMode, + wrapperEl: wrapper, + mountTargetEl: mountTarget, + detachedOriginal, + mountedInstance: null, + mountedVariant: 0, + runtime: null, + propValues: buildSveltePropValuesFromLiveElement(detachedOriginal, manifest), + paramsByVariant, + }; + if (pendingSvelteComponentRetryObserver) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + } + recoveryWaitingForAnchor = false; + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + + const mounted = await mountSvelteComponentVariant(visibleVariant); + if (!mounted) { + // The compiled component threw (e.g. a Svelte compile error in the + // variant file). Don't strand the bar in an empty CYCLING state; restore + // the original element and reset to PICKING so the user can retry. + abortSvelteComponentInjection(sessionId, 'A variant failed to compile. Fix the component and re-run.'); + return; + } + + selectedElement = mountTarget.firstElementChild || mountTarget; + state = 'CYCLING'; + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.'); + } catch (err) { + console.error('[impeccable] Failed to mount Svelte component variants:', err); + abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.'); + } + } + + function waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }) { + if (pendingSvelteComponentRetryObserver) pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = new MutationObserver(() => { + if (svelteComponentSession?.sessionId === sessionId) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + return; + } + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) return; + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + injectSvelteComponentsFromManifest(manifestPath, sessionId); + }); + pendingSvelteComponentRetryObserver.observe(document.body, { childList: true, subtree: true }); + } + + // Reset cleanly when a Svelte component session can't mount: tear the wrapper + // down (restoring the original element), clear persisted session state, and + // return the bar to PICKING. Avoids the stuck 0/0 CYCLING bar. + function abortSvelteComponentInjection(sessionId, message) { + try { + if (svelteComponentSession?.sessionId === sessionId) { + teardownSvelteComponentSession(true); + } else { + const orphan = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (orphan) orphan.remove(); + } + } catch (err) { + console.warn('[impeccable] Svelte component abort cleanup failed:', err); + } + hideShaderOverlay(); + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + stopScrollLock(); + clearSession(); + clearHandled(); + resetSessionFileMeta(); + currentSessionId = null; + expectedVariants = 0; + arrivedVariants = 0; + visibleVariant = 0; + selectedElement = null; + state = 'PICKING'; + hideBar(); + if (message) showToast(message, 5000); } /** @@ -4101,6 +4881,11 @@ * This works even when the dev server caches HTML (Bun, static servers). */ function injectVariantsFromSource(filePath, sessionId) { + if (isSvelteComponentManifestPath(filePath)) { + injectSvelteComponentsFromManifest(filePath, sessionId); + return; + } + rememberSessionFileMeta({ file: filePath }); const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) @@ -4119,7 +4904,7 @@ const doc = parser.parseFromString(block, 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { - console.error('[impeccable] Variant wrapper not found in source file.'); + console.warn('[impeccable] Variant wrapper not found in source file.'); return; } @@ -4134,31 +4919,31 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; - if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - } - + const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); if (!liveEl) { - console.error('[impeccable] Could not find original element in live DOM.'); + console.warn('[impeccable] Could not find original element in live DOM.'); + selectedElement = document.body; + recoveryWaitingForAnchor = true; + state = 'GENERATING'; + showBar('generating'); + saveSession(); + showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } + recoveryWaitingForAnchor = false; // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); arrivedVariants = variants.length; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + recoverEmptyCycling('source-fallback-empty'); + return; + } const saved = loadSession(); const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants @@ -4170,8 +4955,9 @@ selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4184,21 +4970,129 @@ }); } - function cycleVariant(dir) { + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { + const map = new Map(); + if (!sourceOriginal || !liveOriginal) return map; + + const sourceNodes = collectTextNodes(sourceOriginal) + .filter((node) => /\{[^{}]+\}/.test(node.nodeValue || '')); + const liveTexts = collectTextNodes(liveOriginal) + .map((node) => normalizePreviewText(node.nodeValue || '')) + .filter(Boolean); + let liveIndex = 0; + + for (const sourceNode of sourceNodes) { + const sourceText = sourceNode.nodeValue || ''; + const tokens = sourceText.match(/\{[^{}]+\}/g) || []; + if (tokens.length === 0) continue; + + const liveText = liveTexts[liveIndex++] || ''; + if (!liveText) continue; + + if (tokens.length === 1) { + const token = tokens[0]; + const normalizedSource = normalizePreviewText(sourceText); + if (normalizedSource === token) { + map.set(token, liveText); + continue; + } + + const match = liveText.match(expressionTextMatcher(sourceText, [token])); + if (match && match[1]) map.set(token, match[1].trim()); + continue; + } + + if (normalizePreviewText(sourceText) === tokens.join(' ')) { + for (const token of tokens) { + const tokenLiveText = liveTexts[liveIndex - 1] || ''; + if (tokenLiveText) map.set(token, tokenLiveText); + } + } + } + + return map; + } + + function expressionTextMatcher(sourceText, tokens) { + let pattern = '^'; + let cursor = 0; + for (const token of tokens) { + const index = sourceText.indexOf(token, cursor); + if (index === -1) continue; + pattern += escapeRegExp(sourceText.slice(cursor, index)).replace(/\s+/g, '\\s*'); + pattern += '(.*?)'; + cursor = index + token.length; + } + pattern += escapeRegExp(sourceText.slice(cursor)).replace(/\s+/g, '\\s*') + '$'; + return new RegExp(pattern); + } + + function collectTextNodes(root) { + if (!root) return []; + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + nodes.push(node); + node = walker.nextNode(); + } + return nodes; + } + + function normalizePreviewText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + async function selectVariant(next, checkpointReason) { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - const next = visibleVariant + dir; + if (variantSelectionInFlight) return; if (next < 1 || next > arrivedVariants) return; - visibleVariant = next; - showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself - updateSelectedElement(); - updateBarContent('cycling'); - positionBar(); - saveSession(); - queueCheckpoint('variant_changed'); + if (next === visibleVariant) return; + + const previous = visibleVariant; + variantSelectionInFlight = true; + const selectionPromise = (async () => { + visibleVariant = next; + showOrUpdateCyclingBar(); + saveSession(); + const shown = await showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself + if (!shown) { + visibleVariant = previous; + await showVariantInDOM(currentSessionId, previous); + showOrUpdateCyclingBar(); + saveSession(); + return; + } + updateSelectedElement(); + showOrUpdateCyclingBar(); + positionBar(); + saveSession(); + if (checkpointReason) queueCheckpoint(checkpointReason); + })(); + variantSelectionPromise = selectionPromise; + try { + await selectionPromise; + } finally { + if (variantSelectionPromise === selectionPromise) variantSelectionPromise = null; + variantSelectionInFlight = false; + } + } + + function cycleVariant(dir) { + selectVariant(visibleVariant + dir, 'variant_changed'); } function updateSelectedElement() { if (!currentSessionId) return; + if (svelteComponentSession?.sessionId === currentSessionId) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; + return; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); @@ -4206,6 +5100,9 @@ } function readVisibleVariantFromDOM(sessionId) { + if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { + return svelteComponentSession.mountedVariant; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -4341,9 +5238,9 @@ // scrollY that the next resume needs to read. } - // --------------------------------------------------------------------------- + // // MutationObserver for progressive variant reveal - // --------------------------------------------------------------------------- + // function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -4426,10 +5323,11 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); updateSelectedElement(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4445,9 +5343,9 @@ return obs; } - // --------------------------------------------------------------------------- + // // Bar scroll tracking - // --------------------------------------------------------------------------- + // function startScrollTracking() { function tick() { @@ -4483,10 +5381,10 @@ if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; } } - // --------------------------------------------------------------------------- + // // SSE (server→browser) + fetch POST (browser→server) // Zero-dependency replacement for WebSocket. - // --------------------------------------------------------------------------- + // let evtSource = null; let sseRetries = 0; @@ -4509,6 +5407,7 @@ console.log('[impeccable] Live mode connected.'); syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); + restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; syncPageChatFocus('sse-connected'); break; @@ -4531,11 +5430,12 @@ break; case 'done': if (maybeCompleteSteer(msg)) break; + rememberSessionFileMeta(msg); // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); } @@ -4557,7 +5457,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; if (state !== 'GENERATING') return; showToast( - "Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.", + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", 15000, ); }, 2000); @@ -4571,12 +5471,18 @@ // the final complete event. Keep the browser in its recoverable // saving state while the source cleanup is still in flight. break; + case 'discarded': + if (msg.id && msg.id === currentSessionId) { + markSessionHandled(); + cleanup(); + } + break; case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; state = 'CYCLING'; updateBarContent('cycling'); - showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000); + showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; } if (maybeCompleteSteer(msg)) break; @@ -4656,6 +5562,9 @@ expectedVariants, arrivedVariants, visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, paramValues: { ...paramsCurrentValues }, }; } @@ -4665,6 +5574,20 @@ return sendEvent(checkpointPayload(reason)).catch(() => null); } + function sendSteerCheckpoint(id, reason, extra) { + if (!id) return Promise.resolve(null); + return sendEvent({ + type: 'checkpoint', + id, + revision: sessionState.nextCheckpointRevision(), + owner: browserOwner, + phase: 'steer', + reason, + pageUrl: location.pathname, + ...(extra || {}), + }).catch(() => null); + } + function queueCheckpoint(reason) { if (!currentSessionId) return; if (checkpointTimer) clearTimeout(checkpointTimer); @@ -4674,9 +5597,9 @@ }, 120); } - // --------------------------------------------------------------------------- + // // Event handlers - // --------------------------------------------------------------------------- + // function handleMouseMove(e) { if (pendingApplyInFlight) return; @@ -4860,7 +5783,7 @@ // // DISABLED: quick-Go workflows pay an extra harness round trip because // prefetch + generate arrive as two events instead of one. Re-enable with - // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to + // a browser-side debounce (~800-1000ms, cancelled on Go) if we want to // resurrect this. Server validator and skill dispatch remain in place so // flipping this flag is the only change needed. const PREFETCH_ENABLED = false; @@ -4876,6 +5799,14 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + const deepActive = activeElementDeep(); + if ( + deepActive + && own(deepActive) + && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + ) { + return; + } // While a contenteditable text-leaf is focused, let the browser handle // all keys except Escape. Escape cancels the current edit (restores // original text) and blurs without saving, staying in CONFIGURING. @@ -4982,7 +5913,7 @@ if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!selectedElement || state !== 'CONFIGURING') return; stopVoice({ suppressSubmit: true }); - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; // Commit any pending pin edit BEFORE we snapshot annotations. @@ -4996,6 +5927,7 @@ expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); // Flip to GENERATING immediately so the bar morphs without waiting on // capture + upload. The event is emitted from captureAndEmit() once the @@ -5054,7 +5986,7 @@ function handleInsertCreate() { if (!placeholderElement || !insertAnchorElement || state !== 'CONFIGURING' || configureKind !== 'insert') return; - const input = document.getElementById(PREFIX + '-insert-input'); + const input = uiGetById(PREFIX + '-insert-input'); const prompt = input ? input.value.trim() : ''; if (annotEditing) finalizeEditingPin(); const snapshot = { @@ -5064,10 +5996,12 @@ if (!canCreateInsert({ prompt, comments: snapshot.comments, strokes: snapshot.strokes })) return; stopVoice({ suppressSubmit: true }); + pendingAcceptedSession = null; currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); selectedElement = placeholderElement; insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement); @@ -5107,9 +6041,9 @@ captureAndEmit(elForCapture, basePayload, snapshot, captureRect); } - // --------------------------------------------------------------------------- + // // Screenshot capture + upload - // --------------------------------------------------------------------------- + // let msLoadPromise = null; function loadModernScreenshot() { @@ -5120,7 +6054,7 @@ s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; s.onload = () => resolve(window.modernScreenshot); s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; - document.head.appendChild(s); + uiAppendStyle(s); }); return msLoadPromise; } @@ -5235,11 +6169,113 @@ return '#ffffff'; } + function captureChromeNodes() { + const nodes = []; + const add = (node) => { + if (!node || node === document.body || nodes.includes(node)) return; + nodes.push(node); + }; + add(document.getElementById(PREFIX + '-root')); + [ + PREFIX + '-highlight', + PREFIX + '-tooltip', + PREFIX + '-bar', + PREFIX + '-picker', + PREFIX + '-params-panel', + PREFIX + '-insert-line', + PREFIX + '-insert-placeholder', + PREFIX + '-insert-create-tooltip', + PREFIX + '-annot', + PREFIX + '-design-host', + PREFIX + '-toast', + PREFIX + '-shader', + ].forEach((id) => add(uiGetById(id))); + return nodes; + } + + async function hideCaptureChromeForShaderProxy(fn) { + const saved = captureChromeNodes().map((node) => ({ + node, + visibility: node.style.visibility, + priority: node.style.getPropertyPriority('visibility'), + })); + for (const { node } of saved) { + node.style.setProperty('visibility', 'hidden', 'important'); + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + try { + return await fn(); + } finally { + for (const { node, visibility, priority } of saved) { + node.style.setProperty('visibility', visibility, priority); + } + } + } + + function shouldUseAncestorCropShaderProxy(el) { + // TODO: Enable this proxy for React/Vue/etc. adapters once their live + // preview mounts are covered by the same shader regression checks. + const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase(); + if (adapter === 'svelte' || adapter === 'sveltekit') return true; + if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true; + const wrapper = el?.closest?.('[data-impeccable-variants]'); + return wrapper?.dataset?.impeccablePreview === 'svelte-component'; + } + + function paintsShaderProxySurface(node) { + const s = getComputedStyle(node); + return !isTransparentColor(s.backgroundColor) + || (s.backgroundImage && s.backgroundImage !== 'none') + || paintsBackdrop(node); + } + + function findShaderProxyCaptureRoot(el) { + const doc = el.ownerDocument || document; + const er = el.getBoundingClientRect(); + let node = el.parentElement; + while (node && node !== doc.documentElement) { + const nr = node.getBoundingClientRect(); + const containsElement = + nr.width > 0 && nr.height > 0 && + nr.left <= er.left + 0.5 && + nr.top <= er.top + 0.5 && + nr.right >= er.right - 0.5 && + nr.bottom >= er.bottom - 0.5; + if (containsElement && paintsShaderProxySurface(node)) return node; + node = node.parentElement; + } + return null; + } + // Capture the element (with current annotations baked in) and return // { blob, paper }: the PNG Blob, plus the representative backdrop tone for the // shader's halftone ground (so capture, upload, and shader all agree on what // sits behind the element). Shared between the Go flow (uploads the blob) and // the shader-resume path. + async function captureElementFromRenderedAncestor(ms, el, opts) { + const doc = el.ownerDocument || document; + const captureRoot = findShaderProxyCaptureRoot(el); + if (!captureRoot) throw new Error('No painted ancestor for Svelte shader proxy'); + const rootCanvas = await ms.domToCanvas(captureRoot, opts); + const S = opts.scale; + const er = el.getBoundingClientRect(); + const rr = captureRoot.getBoundingClientRect(); + const sx = (er.left - rr.left) * S; + const sy = (er.top - rr.top) * S; + const sw = er.width * S; + const sh = er.height * S; + if (sw <= 0 || sh <= 0) throw new Error('Selected element has no visible capture rect'); + const crop = doc.createElement('canvas'); + crop.width = Math.max(1, Math.round(sw)); + crop.height = Math.max(1, Math.round(sh)); + const cctx = crop.getContext('2d', { willReadFrequently: true }); + cctx.drawImage(rootCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height); + const paper = dominantRgb01(cctx, crop.width, crop.height) || averageRgb01(cctx, crop.width, crop.height); + const blob = await new Promise((res) => crop.toBlob(res, 'image/png')); + if (!blob) throw new Error('Ancestor crop failed to produce a PNG blob'); + return { blob, paper }; + } + async function captureElementToBlob(el, snapshot, rect) { try { if (document.fonts?.ready) await document.fonts.ready; } catch {} const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); @@ -5261,6 +6297,13 @@ scale: Math.min(window.devicePixelRatio || 1, 2), font: fontCssText ? { cssText: fontCssText } : undefined, }; + if (shouldUseAncestorCropShaderProxy(el)) { + try { + return await hideCaptureChromeForShaderProxy(() => captureElementFromRenderedAncestor(ms, el, opts)); + } catch (err) { + console.warn('[impeccable] Svelte ancestor crop capture failed, falling back to element capture:', err); + } + } const bg = resolveCanvasBackground(el); // Fast path: the element paints its own background, or an opaque ancestor // color was found. modern-screenshot bakes that color; paper matches it. @@ -5342,13 +6385,13 @@ sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); } - // --------------------------------------------------------------------------- + // // Shader overlay - renders the captured screenshot as a WebGL texture and // runs an editorial "ink-wash" fragment shader over it during generation. // A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku // and leaving a soft trail. Makes the wait feel like a letterpress scan // instead of a dead spinner. - // --------------------------------------------------------------------------- + // const SHADER_VS = `attribute vec2 a_position; attribute vec2 a_uv; @@ -5502,6 +6545,31 @@ void main() { return n ? [r / n / 255, g / n / 255, b / n / 255] : SHADER_PAPER_FALLBACK; } + // Pick the most common visible color cluster from a crop. A straight average + // gets pulled by text and icons; the dominant bucket usually represents the + // surface the shader should dissolve into. + function dominantRgb01(ctx, w, h) { + const data = ctx.getImageData(0, 0, w, h).data; + const stride = Math.max(1, Math.floor((w * h) / 6000)); + const buckets = new Map(); + for (let p = 0; p < w * h; p += stride) { + const i = p * 4; + if (data[i + 3] < 16) continue; + const key = (data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4); + const bucket = buckets.get(key) || { count: 0, r: 0, g: 0, b: 0 }; + bucket.count += 1; + bucket.r += data[i]; + bucket.g += data[i + 1]; + bucket.b += data[i + 2]; + buckets.set(key, bucket); + } + let best = null; + for (const bucket of buckets.values()) { + if (!best || bucket.count > best.count) best = bucket; + } + return best ? [best.r / best.count / 255, best.g / best.count / 255, best.b / best.count / 255] : null; + } + // Average the backdrop sampled just OUTSIDE an element's rect within a larger // canvas. The ground tone for the dissolve must be the real backdrop, not the // mean of the element's own crop - averaging the crop folds in the element's @@ -5572,7 +6640,7 @@ void main() { fallback.style.backgroundRepeat = 'no-repeat'; fallback.style.outline = '2px dashed ' + C.brand; fallback.style.outlineOffset = '-2px'; - document.body.appendChild(fallback); + uiAppend(fallback); shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl }; } @@ -5582,16 +6650,19 @@ void main() { const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); + const radius = getComputedStyle(el).borderRadius; canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); Object.assign(canvas.style, { position: 'fixed', top: rect.top + 'px', left: rect.left + 'px', width: rect.width + 'px', height: rect.height + 'px', + borderRadius: radius, + overflow: 'hidden', pointerEvents: 'none', zIndex: Z.bar - 1, }); - document.body.appendChild(canvas); + uiAppend(canvas); const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) || canvas.getContext('experimental-webgl'); @@ -5685,8 +6756,12 @@ void main() { frame(); } - function handleAccept() { + async function handleAccept() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } + if (pendingAcceptedSession || state === 'SAVING') return; + if (variantSelectionPromise) { + try { await variantSelectionPromise; } catch { /* failed selection falls back below */ } + } if (!currentSessionId || arrivedVariants === 0) return; const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId); if (domVisibleVariant > 0) visibleVariant = domVisibleVariant; @@ -5696,30 +6771,39 @@ void main() { variantId: String(visibleVariant), pageUrl: location.pathname, }; + const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } + // The accepted variant is already the only visible child of the wrapper + // (all other variants are display:none). HMR from the source rewrite will + // replace the wrapper imminently. Don't eagerly replaceChild here - React + // reconciliation races with our mutation and throws NotFoundError in Next + // 16 / Turbopack. Schedule a fallback that runs the manual swap only if + // HMR hasn't cleaned up by then (keeps static-server flows working). const acceptedSessionId = currentSessionId; const acceptedVariant = visibleVariant; + const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId + || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - pendingAcceptedSession = { - id: acceptedSessionId, - variant: String(acceptedVariant), - ...acceptedSnapshot, - finalizing: false, - }; state = 'SAVING'; updateBarContent('saving'); + pendingAcceptedSession = { + id: acceptedSessionId, + variant: String(acceptedVariant), + isSvelteComponent: acceptedIsSvelteComponent, + ...acceptedSnapshot, + finalizing: false, + }; + saveSession(); sendEvent(acceptPayload, { throwOnError: true }) - .then(() => { - markSessionHandled(); - }) + .then(() => {}) .catch(() => { - pendingAcceptedSession = null; + if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); } @@ -5733,19 +6817,21 @@ void main() { } if (pending.finalizing) return true; pending.finalizing = true; - + markSessionHandled(); + if (pending.isSvelteComponent) { + commitAcceptedSvelteComponentToDom(pending.id); + } state = 'CONFIRMED'; updateBarContent('confirmed'); + scheduleAcceptCleanup(pending); + return true; + } - // Give framework HMR a short chance to render the now-clean accepted - // source. If it misses the update, unwrap the accepted variant after the - // source-side completion event so the page is not left empty or stale. + function scheduleAcceptCleanup(accepted) { setTimeout(function() { - ensureAcceptedDomClean(pending); + if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted); cleanupAcceptedSession(); }, 1200); - - return true; } function snapshotAcceptedVariantDom(sessionId, variantId) { @@ -5833,6 +6919,7 @@ void main() { stopScrollLock(); clearScrollY(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5841,6 +6928,28 @@ void main() { state = 'PICKING'; } + function commitAcceptedVariantToDom(sessionId, variantId) { + const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapper) return false; + const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); + if (!accepted || !accepted.firstElementChild) return false; + const parent = wrapper.parentElement; + if (!parent) return false; + + const style = wrapper.querySelector('style[data-impeccable-css]'); + if (style && !document.querySelector('style[data-impeccable-accepted-css="' + sessionId + '"]')) { + const promotedStyle = style.cloneNode(true); + promotedStyle.setAttribute('data-impeccable-accepted-css', sessionId); + parent.insertBefore(promotedStyle, wrapper); + } + + const committed = accepted.cloneNode(true); + committed.removeAttribute('hidden'); + committed.style.display = 'contents'; + parent.replaceChild(committed, wrapper); + return true; + } + function handleDiscard() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!currentSessionId) return; @@ -5852,11 +6961,141 @@ void main() { .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000)); } - // --------------------------------------------------------------------------- + // // Session persistence via live-browser-session.js - // --------------------------------------------------------------------------- + // // Survives page reloads, browser close/reopen, HMR, and accidental refreshes. + function normalizeSessionPath(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed.replace(/\\/g, '/') : null; + } + + function resetSessionFileMeta() { + currentSourceFile = null; + currentPreviewFile = null; + currentPreviewMode = null; + recoveryWaitingForAnchor = false; + } + + function rememberSessionFileMeta(meta = {}) { + const file = normalizeSessionPath(meta.file); + const sourceFile = normalizeSessionPath(meta.sourceFile); + const previewFile = normalizeSessionPath(meta.previewFile); + const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null); + + if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) { + currentPreviewMode = 'svelte-component'; + currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile); + currentSourceFile = sourceFile || currentSourceFile; + return; + } + + if (sourceFile || file) currentSourceFile = sourceFile || file; + if (previewFile) currentPreviewFile = previewFile; + if (previewMode) currentPreviewMode = previewMode; + } + + function applySavedSessionMeta(saved) { + if (!saved) return; + rememberSessionFileMeta(saved); + if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.action) selectedAction = saved.action; + if (saved.count) selectedCount = saved.count; + if (saved.previewMode) currentPreviewMode = saved.previewMode; + if (saved.paramValues && typeof saved.paramValues === 'object') { + paramsCurrentValues = { ...saved.paramValues }; + } + } + + function normalizePagePath(value) { + if (!value || typeof value !== 'string') return null; + try { + return new URL(value, location.origin).pathname; + } catch { + return value.split(/[?#]/)[0] || null; + } + } + + function pageMatchesCurrent(value) { + const path = normalizePagePath(value); + return !path || path === location.pathname; + } + + function isTerminalSessionSummary(session) { + return /^(completed|discarded|discard_requested|accept_requested)$/.test(String(session?.phase || '')); + } + + function findActiveSessionSummary(saved, activeSessions) { + if (!saved?.id || !Array.isArray(activeSessions)) return null; + return activeSessions.find((session) => + session?.id === saved.id + && pageMatchesCurrent(session.pageUrl || saved.pageUrl) + && !isTerminalSessionSummary(session) + ) || null; + } + + function clampVariantIndex(value, count) { + const num = Number(value); + const max = Number(count); + if (!Number.isFinite(num) || num < 1) return 0; + if (Number.isFinite(max) && max > 0 && num > max) return 0; + return Math.floor(num); + } + + function restoreSessionWithoutWrapper(reason, activeSessions) { + const saved = loadSession(); + if (!saved?.id || isSessionHandled(saved.id)) return false; + const savedState = String(saved.state || '').toUpperCase(); + if (savedState !== 'GENERATING' && savedState !== 'CYCLING') return false; + + const serverSession = findActiveSessionSummary(saved, activeSessions); + if (Array.isArray(activeSessions) && activeSessions.length > 0 && !serverSession) { + return false; + } + + currentSessionId = saved.id; + applySavedSessionMeta(serverSession); + applySavedSessionMeta(saved); + + expectedVariants = Number(saved.expected || serverSession?.expectedVariants || selectedCount || 0); + arrivedVariants = Number(saved.arrived || serverSession?.arrivedVariants || 0); + if (arrivedVariants <= 0 && currentPreviewFile) arrivedVariants = Number(serverSession?.expectedVariants || saved.expected || selectedCount || 0); + if (expectedVariants <= 0) expectedVariants = Number(serverSession?.expectedVariants || arrivedVariants || selectedCount || 0); + visibleVariant = clampVariantIndex(saved.visible, arrivedVariants || expectedVariants) + || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) + || (arrivedVariants > 0 ? 1 : 0); + + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + if (variantObserver) variantObserver.disconnect(); + variantObserver = startVariantObserver(currentSessionId); + saveSession(); + queueCheckpoint(reason || 'browser_restore_without_wrapper'); + + const restoreFile = currentPreviewMode === 'svelte-component' + ? currentPreviewFile + : (currentSourceFile || currentPreviewFile); + if (restoreFile) { + injectVariantsFromSource(restoreFile, currentSessionId); + return true; + } + + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return true; + } + + function restoreFromActiveSessions(activeSessions, reason) { + const wrapper = document.querySelector('[data-impeccable-variants]'); + if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false; + if (svelteComponentSession?.sessionId === currentSessionId) return false; + return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); + } + function saveSession() { if (!currentSessionId) return; // NOTE: scrollY is stored under a separate key (writeScrollY). Storing @@ -5869,6 +7108,11 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, + pageUrl: location.pathname, + paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, }); } @@ -5898,31 +7142,33 @@ void main() { } function cleanup() { - // Hide the wrapper immediately so variants disappear. DON'T structurally - // mutate the DOM yet - HMR from the agent's source rewrite is on its way, - // and a manual replaceChild under React causes NotFoundError when the - // reconciler later tries to remove a wrapper we already removed. - // Schedule a 2s fallback that does the manual swap only if HMR hasn't - // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const cleanupSessionId = currentSessionId; - if (cleanupSessionId) { + if (svelteComponentSession?.sessionId === cleanupSessionId) { + teardownSvelteComponentSession(true); + } else if (cleanupSessionId) { + // Hide the wrapper immediately so variants disappear. DON'T structurally + // mutate the DOM yet - HMR from the agent's source rewrite is on its way, + // and a manual replaceChild under React causes NotFoundError when the + // reconciler later tries to remove a wrapper we already removed. + // Schedule a 2s fallback that does the manual swap only if HMR hasn't + // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); if (wrapper) wrapper.style.display = 'none'; - } - setTimeout(function() { - if (!cleanupSessionId) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!wrapper) return; - const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - wrapper.parentElement.replaceChild(content, wrapper); - return; + setTimeout(function() { + if (!cleanupSessionId) return; + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) return; + const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); + if (orig) { + const content = orig.firstElementChild; + if (content) { + lateWrapper.parentElement.replaceChild(content, lateWrapper); + return; + } } - } - wrapper.remove(); - }, 2000); + lateWrapper.remove(); + }, 2000); + } hideBar(); hideHighlight(); stopScrollTracking(); @@ -5931,6 +7177,7 @@ void main() { clearScrollY(); finalizeInsertSession(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5938,9 +7185,9 @@ void main() { state = 'PICKING'; } - // --------------------------------------------------------------------------- + // // Toast - // --------------------------------------------------------------------------- + // function showToast(message, duration) { if (toastEl) toastEl.remove(); @@ -5964,7 +7211,7 @@ void main() { }); toastEl.id = PREFIX + '-toast'; toastEl.textContent = message; - document.body.appendChild(toastEl); + uiAppend(toastEl); requestAnimationFrame(() => { toastEl.style.opacity = '1'; toastEl.style.transform = 'translateX(-50%) translateY(0)'; @@ -5978,22 +7225,70 @@ void main() { }, duration); } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. function resumeSession() { const wrapper = document.querySelector('[data-impeccable-variants]'); - if (!wrapper) { clearSession(); clearHandled(); return false; } + if (!wrapper) { + if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } const sessionId = wrapper.dataset.impeccableVariants; // Don't resume if this session was already accepted/discarded if (isSessionHandled(sessionId)) return false; + // Svelte component sessions can't be resumed by counting DOM children: the + // wrapper holds a single mount target, not [data-impeccable-variant] nodes, + // and a page reload unmounts every compiled variant. Counting children here + // would strand the bar in CYCLING at 0/0. If there's no live in-memory mount + // for this wrapper, it's an orphan (reload / failed mount): drop it and let + // the live-server's SSE re-inject the manifest if the session is still live. + if (wrapper.dataset.impeccablePreview === 'svelte-component' + && svelteComponentSession?.sessionId !== sessionId) { + wrapper.remove(); + if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } + + if (wrapper.dataset.impeccablePreview === 'svelte-component') { + if (!svelteComponentSession?.mountedVariant) { + return true; + } + currentSessionId = sessionId; + expectedVariants = Number(wrapper.dataset.impeccableVariantCount) + || Number(svelteComponentSession.manifest?.count) + || expectedVariants + || 1; + arrivedVariants = expectedVariants; + const saved = loadSession(); + applySavedSessionMeta(saved); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = svelteComponentSession.mountedVariant > 0 && svelteComponentSession.mountedVariant <= arrivedVariants + ? svelteComponentSession.mountedVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = resolveSvelteComponentAnchor() + || wrapper.parentElement; + state = 'CYCLING'; + hideShaderOverlay(); + showBar('cycling'); + startScrollTracking(); + refreshParamsPanel(); + saveSession(); + queueCheckpoint('browser_resumed_svelte_component'); + return true; + } + currentSessionId = sessionId; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -6002,6 +7297,7 @@ void main() { // Restore state from localStorage if available const saved = loadSession(); if (saved && saved.id === sessionId) { + applySavedSessionMeta(saved); visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0); if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; @@ -6072,9 +7368,9 @@ void main() { return true; } - // --------------------------------------------------------------------------- + // // Global bar (always visible at bottom) - // --------------------------------------------------------------------------- + // let globalBarEl = null; let globalBarBrandEl = null; @@ -6166,6 +7462,8 @@ void main() { let pageChatExpanded = false; let steerLocked = false; let steerRequestId = null; + let steerPendingMessage = ''; + let steerInputWasFocused = false; let pageChatDotsEl = null; let steerAwaitTimer = null; let voiceRecognition = null; @@ -6179,7 +7477,7 @@ void main() { const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; - const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect'; + const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect'; const GLOBAL_BAR_SECTION_GAP = 8; const GLOBAL_BAR_INNER_GAP = 2; const GLOBAL_BAR_INNER_PAD_LEFT = 2; @@ -6323,7 +7621,7 @@ void main() { const attempt = () => { steerFocusRecoverTimer = null; if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (pageHasHostTextSelection()) { steerFocusRecoverTimer = setTimeout(attempt, 120); return; @@ -6344,7 +7642,7 @@ void main() { steerFocusSuspended = true; steerFocusPauseUntil = performance.now() + STEER_PAGE_FOCUS_PAUSE_MS; pagePointerGesture = { x: e.clientX, y: e.clientY, dragged: false }; - if (pageChatInput && document.activeElement === pageChatInput) { + if (pageChatInput && activeElementDeep() === pageChatInput) { pageChatInput.blur(); } } @@ -6404,7 +7702,7 @@ void main() { pickActive, pageChatReady: !!pageChatInput, pageChatExpanded, - active: steerFocusTargetLabel(document.activeElement), + active: steerFocusTargetLabel(activeElementDeep()), shouldSteer: shouldFocusSteerChat(), ...(extra || {}), }); @@ -6423,26 +7721,26 @@ void main() { function focusConfigureInput(reason) { steerFocusLog('focusConfigureInput', { reason }); const inputId = configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input'; - const input = document.getElementById(inputId); + const input = uiGetById(inputId); if (!input) { steerFocusLog('focusConfigureInput missing', { reason }); return; } setTimeout(() => { - const before = document.activeElement; + const before = activeElementDeep(); input.focus(); steerFocusLog('focusConfigureInput result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== input, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== input, }); }, 60); } function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; - const focused = document.activeElement === pageChatInput; + const focused = activeElementDeep() === pageChatInput; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; const P = pageChatPalette(); pageChatEl.style.borderColor = steerLocked @@ -6476,15 +7774,15 @@ void main() { } syncPageChatVisual(); pageChatInput.style.pointerEvents = 'auto'; - const before = document.activeElement; + const before = activeElementDeep(); try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== pageChatInput, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== pageChatInput, }); } @@ -6515,6 +7813,37 @@ void main() { return wrap; } + function keepSteerPointerInside(e, opts = {}) { + e.stopPropagation(); + if (opts.preventDefault !== false) e.preventDefault(); + } + + function preparePageChatInputForTyping() { + if (!pageChatEl || !pageChatInput) return false; + pageChatExpanded = true; + pageChatEl.dataset.expanded = 'true'; + pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; + pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return true; + } + + function focusPageChatInput(reason) { + if (!preparePageChatInputForTyping() || steerLocked) return false; + try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } + const focused = activeElementDeep() === pageChatInput; + if (focused) steerInputWasFocused = true; + syncPageChatFocusRing(); + return focused; + } + function clearSteerAwaitTimer() { if (steerAwaitTimer) { clearTimeout(steerAwaitTimer); @@ -6528,6 +7857,7 @@ void main() { if (!steerLocked || steerRequestId !== id) return; unlockSteerChat({ error: 'Steer timed out waiting for the agent. Check that live-poll is running and replies with steer_done.', + restoreMessage: steerPendingMessage, }); }, STEER_AWAIT_TIMEOUT_MS); } @@ -6538,19 +7868,12 @@ void main() { steerLocked = true; pageChatEl.dataset.processing = 'true'; pageChatInput.disabled = true; - pageChatInput.value = ''; - pageChatInput.blur(); + preparePageChatInputForTyping(); if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = true; pageChatVoiceBtn.style.display = 'none'; } - pageChatExpanded = false; - pageChatEl.dataset.expanded = 'false'; - pageChatEl.style.width = PAGE_CHAT_PROCESSING_W; pageChatEl.style.cursor = 'default'; - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; pageChatInput.style.pointerEvents = 'none'; if (pageChatHint) { pageChatHint.style.display = 'none'; @@ -6568,17 +7891,26 @@ void main() { function unlockSteerChat(opts) { clearSteerAwaitTimer(); + const restoreMessage = typeof opts?.restoreMessage === 'string' ? opts.restoreMessage : ''; + const keepExpanded = Boolean(opts?.error && restoreMessage); steerLocked = false; + const completedId = steerRequestId; steerRequestId = null; if (!pageChatEl) return; pageChatEl.dataset.processing = 'false'; pageChatEl.removeAttribute('aria-busy'); pageChatEl.setAttribute('aria-label', 'Steer the page'); - pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W; + pageChatExpanded = keepExpanded; + pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false'; + pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W; pageChatEl.style.cursor = 'pointer'; if (pageChatInput) { pageChatInput.disabled = false; - pageChatInput.value = ''; + pageChatInput.value = keepExpanded ? restoreMessage : ''; + pageChatInput.style.width = keepExpanded ? '' : '0'; + pageChatInput.style.padding = keepExpanded ? '0 6px' : '0'; + pageChatInput.style.opacity = keepExpanded ? '1' : '0'; + pageChatInput.style.pointerEvents = 'auto'; } if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = false; @@ -6586,18 +7918,28 @@ void main() { } if (pageChatHint) { pageChatHint.textContent = 'Steer'; - pageChatHint.style.display = ''; - pageChatHint.style.visibility = ''; + pageChatHint.style.display = keepExpanded ? 'none' : ''; + pageChatHint.style.visibility = keepExpanded ? 'hidden' : ''; + pageChatHint.style.opacity = keepExpanded ? '0' : '1'; } if (pageChatDotsEl?.parentNode) { pageChatDotsEl.remove(); pageChatDotsEl = null; } + steerPendingMessage = keepExpanded ? restoreMessage : ''; + steerInputWasFocused = false; syncPageChatChrome(); syncPageChatFocusRing(); if (opts?.error) showToast(String(opts.error), 5000); else if (opts?.message) showToast(String(opts.message), 4000); - syncPageChatFocus('steer-unlock'); + if (completedId) { + sendSteerCheckpoint(completedId, opts?.error ? 'steer_error' : 'steer_done', { + message: opts?.message || opts?.error || '', + file: opts?.file || '', + }); + } + if (keepExpanded) focusPageChatInput('steer-error-restore'); + else syncPageChatFocus('steer-unlock'); } function steerSpeechRecognitionCtor() { @@ -6651,7 +7993,7 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = document.getElementById(PREFIX + '-configure-voice'); + const voiceBtn = uiGetById(PREFIX + '-configure-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -6784,7 +8126,7 @@ void main() { } function configureVoiceContext() { - const input = document.getElementById( + const input = uiGetById( configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input', ); return { @@ -6819,26 +8161,37 @@ void main() { if (!text || steerLocked) return; const id = id8(); steerRequestId = id; + steerPendingMessage = text; + if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); lockSteerChat(); scheduleSteerAwaitTimeout(id); + sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); sendEvent({ type: 'steer', id, message: text, pageUrl: location.href, }).then((res) => { - if (!res) unlockSteerChat({ error: 'Could not reach live server' }); + if (!res) { + sendSteerCheckpoint(id, 'steer_send_failed', { message: text }); + unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text }); + } }); } function maybeCompleteSteer(msg) { if (!steerRequestId || msg.id !== steerRequestId) return false; if (msg.type === 'steer_done') { - unlockSteerChat({ message: msg.message }); + unlockSteerChat({ message: msg.message, file: msg.file }); + if (msg.file && /\.svelte(?:$|\?)/.test(String(msg.file))) { + setTimeout(() => { + if (!steerLocked) showToast('Steer applied. Reload if the page has not refreshed yet.', 5000); + }, 4500); + } return true; } if (msg.type === 'error') { - unlockSteerChat({ error: msg.message || 'Steer failed' }); + unlockSteerChat({ error: msg.message || 'Steer failed', restoreMessage: steerPendingMessage }); return true; } return false; @@ -6847,21 +8200,10 @@ void main() { function expandPageChat(opts) { const focus = !opts || opts.focus !== false; if (!pageChatEl || !pageChatInput || steerLocked) return; - pageChatExpanded = true; - pageChatEl.dataset.expanded = 'true'; - pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; - pageChatEl.style.cursor = 'text'; - if (pageChatHint) { - pageChatHint.style.display = 'none'; - pageChatHint.style.opacity = '0'; - } - pageChatInput.style.width = ''; - pageChatInput.style.padding = '0 6px'; - pageChatInput.style.opacity = '1'; - pageChatInput.style.pointerEvents = 'auto'; + preparePageChatInputForTyping(); syncPageChatChrome(); syncPageChatFocusRing(); - if (focus) pageChatInput.focus(); + if (focus) focusPageChatInput('expand-page-chat'); } function collapsePageChat(opts) { @@ -6878,7 +8220,7 @@ void main() { } else { pageChatInput.style.pointerEvents = 'auto'; } - if (pageChatHint && document.activeElement !== pageChatInput) { + if (pageChatHint && activeElementDeep() !== pageChatInput) { pageChatHint.style.display = ''; pageChatHint.style.opacity = '1'; } @@ -6952,7 +8294,7 @@ void main() { pageChatEl.appendChild(pageChatInput); pageChatEl.appendChild(pageChatVoiceBtn); - if (!document.getElementById(PREFIX + '-page-chat-style')) { + if (!uiGetById(PREFIX + '-page-chat-style')) { const s = document.createElement('style'); s.id = PREFIX + '-page-chat-style'; s.textContent = @@ -6966,23 +8308,34 @@ void main() { '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } - pageChatEl.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { + keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat(); + expandPageChat({ focus: false }); + focusPageChatInput('page-chat-click'); }); - pageChatVoiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); + pageChatVoiceBtn.addEventListener('mousedown', keepSteerPointerInside); pageChatVoiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); + keepSteerPointerInside(e); if (steerLocked) return; toggleSteerVoice(); }); + pageChatInput.addEventListener('pointerdown', keepSteerPointerInside); + pageChatInput.addEventListener('mousedown', keepSteerPointerInside); + pageChatInput.addEventListener('click', (e) => { + keepSteerPointerInside(e); + if (!steerLocked) focusPageChatInput('page-chat-input-click'); + }); + pageChatInput.addEventListener('input', () => { syncPageChatVisual(); }); @@ -6995,7 +8348,7 @@ void main() { syncPageChatFocusRing(); setTimeout(() => { if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (!pageChatInput.value.trim()) collapsePageChat(); scheduleSteerFocusRecover('steer-blur-recover'); }, 120); @@ -7039,7 +8392,7 @@ void main() { globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false'; globalBarBrandEl.setAttribute('aria-label', connected ? 'Impeccable live mode' - : 'Impeccable live mode: agent not polling'); + : 'Impeccable live mode - agent not polling'); globalBarBrandEl.removeAttribute('title'); globalBarBrandEl.style.cursor = connected ? 'default' : 'help'; const mark = globalBarBrandEl.querySelector('[data-brand-mark]'); @@ -7077,7 +8430,7 @@ void main() { }); agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip'; agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP; - document.body.appendChild(agentPollTooltipEl); + uiAppend(agentPollTooltipEl); return agentPollTooltipEl; } @@ -7131,7 +8484,7 @@ void main() { // Custom focus-visible for bar buttons. Browser default is a heavy // blue ring that looks jarring on the dark capsule. Replace with a // soft accent-tinted inner ring that respects the bar's palette. - if (!document.getElementById(PREFIX + '-bar-focus-style')) { + if (!uiGetById(PREFIX + '-bar-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-bar-focus-style'; s.textContent = @@ -7143,7 +8496,7 @@ void main() { '@keyframes impeccable-agent-dot { 0%, 100% { opacity: 0.45; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1); } }' + '#' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: impeccable-agent-dot 1.4s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: none; opacity: 0.9; } }'; - document.head.appendChild(s); + uiAppendStyle(s); } globalBarEl = el('div', { @@ -7176,7 +8529,7 @@ void main() { brand.id = PREFIX + '-global-bar-brand'; brand.dataset.agentConnected = 'false'; brand.setAttribute('role', 'img'); - brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling'); + brand.setAttribute('aria-label', 'Impeccable live mode - agent not polling'); const brandMark = el('span', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -7211,7 +8564,7 @@ void main() { inner.id = PREFIX + '-global-bar-inner'; globalBarEl.appendChild(inner); - // --- button factory: icon-only at rest, label slides in on hover/active --- + // Button factory: icon-only at rest, label slides in on hover/active. function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { const b = el('button', { position: 'relative', @@ -7506,6 +8859,7 @@ void main() { color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', }); + exitBtn.id = PREFIX + '-exit'; exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = 'oklch(58% 0.15 35)'; exitBtn.style.background = P.exitHover; }); @@ -7530,8 +8884,8 @@ void main() { try { window.focus(); } catch { /* in-app preview may block */ } }, true); - document.body.appendChild(pendingDockEl); - document.body.appendChild(globalBarEl); + uiAppend(pendingDockEl); + uiAppend(globalBarEl); defangOutsideHandlers(pendingDockEl); defangOutsideHandlers(globalBarEl); @@ -7553,11 +8907,11 @@ void main() { } function updateGlobalBarState() { - const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); - const detectBadge = document.getElementById(PREFIX + '-detect-badge'); - const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); - const insertToggle = document.getElementById(PREFIX + '-insert-toggle'); - const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const detectToggle = uiGetById(PREFIX + '-detect-toggle'); + const detectBadge = uiGetById(PREFIX + '-detect-badge'); + const pickToggle = uiGetById(PREFIX + '-pick-toggle'); + const insertToggle = uiGetById(PREFIX + '-insert-toggle'); + const designToggle = uiGetById(PREFIX + '-design-toggle'); const theme = globalBarEl?.dataset.theme || 'light'; const P = barPaletteForTheme(theme); @@ -7751,8 +9105,9 @@ void main() { pendingApplyInFlight = false; } if (globalBarEl) { - globalBarEl.style.transform = 'translateY(100%)'; - setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300); + globalBarEl.style.transition = 'none'; + globalBarEl.remove(); + globalBarEl = null; } pageChatEl = null; pageChatInput = null; @@ -7765,6 +9120,7 @@ void main() { if (barEl) { barEl.remove(); barEl = null; } if (pickerEl) { pickerEl.remove(); pickerEl = null; } if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; } + if (editBadgeProxyRoot) { editBadgeProxyRoot.remove(); editBadgeProxyRoot = null; editBadgeProxyByTarget = new Map(); } if (evtSource) { evtSource.close(); evtSource = null; } document.removeEventListener('mousemove', handleMouseMove, true); document.removeEventListener('click', handleClick, true); @@ -7777,9 +9133,9 @@ void main() { console.log('[impeccable] Live mode exited.'); } - // --------------------------------------------------------------------------- + // // Design System Panel - visualizes the project's .impeccable/design.json sidecar - // --------------------------------------------------------------------------- + // const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; const DESIGN_PANEL_WIDTH = 440; @@ -7847,7 +9203,7 @@ void main() { root.className = 'root'; designShadow.appendChild(root); - document.body.appendChild(designHost); + uiAppend(designHost); // The host is pointer-events: none; the panel inside the shadow DOM // manages its own auto/none. Events bubble through the shadow boundary, // so attaching here silences host-page outside-interaction handlers @@ -7889,7 +9245,7 @@ void main() { .root * { box-sizing: border-box; } button { font: inherit; color: inherit; } - /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + /* Panel shell: chrome matches the bar; body canvas stays neutral */ .panel { position: fixed; top: 12px; bottom: 72px; right: 12px; width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); @@ -7955,7 +9311,7 @@ void main() { .panel-body::-webkit-scrollbar { width: 8px; } .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } - /* --- States --- */ + /* States */ .empty, .loading, .error { margin: 16px 4px; padding: 28px 20px; text-align: center; @@ -7966,7 +9322,7 @@ void main() { .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; } .error { color: oklch(45% 0.15 25); } - /* --- Stale hint --- */ + /* Stale hint */ .stale { display: flex; align-items: center; gap: 8px; margin: 8px 4px 12px; @@ -7979,7 +9335,7 @@ void main() { .stale-text { flex: 1; min-width: 0; } .stale-text strong { color: ${DP.ink}; font-weight: 600; } - /* --- Parsed-md fallback banner --- */ + /* Parsed-md fallback banner */ .parsed-md-cta { margin: 8px 4px 14px; padding: 14px 16px; @@ -7991,7 +9347,7 @@ void main() { .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; } .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; } - /* --- Tile primitives --- */ + /* Tile primitives */ .tile { position: relative; background: ${DP.tile}; @@ -8010,7 +9366,7 @@ void main() { } .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; } - /* --- Color tile --- */ + /* Color tile */ .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; } .c-tile:hover { transform: translateY(-1px); } .c-hero { @@ -8025,7 +9381,7 @@ void main() { .c-ramp > span { flex: 1; } .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Type tile --- */ + /* Type tile */ .t-tile { } .t-specimen { margin: 4px 0 6px; @@ -8035,7 +9391,7 @@ void main() { .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; } .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Shadow tile --- */ + /* Shadow tile */ .s-tile { } .s-surface { height: 60px; margin: 8px 2px 10px; @@ -8045,14 +9401,14 @@ void main() { .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; } .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; } - /* --- Radii strip --- */ + /* Radii strip */ .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; } .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; } .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); } .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; } .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; } - /* --- Component tile (hosts live primitives) --- */ + /* Component tile (hosts live primitives) */ .cmp-tile { } .cmp-stage { margin: 12px -4px 0; @@ -8066,7 +9422,7 @@ void main() { .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; } .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; } - /* --- Collapsible --- */ + /* Collapsible */ .coll { margin: 0 4px 8px; background: ${DP.tile}; @@ -8131,7 +9487,7 @@ void main() { .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; } .coll .overview-body li { margin-bottom: 3px; } - /* --- raw tab markdown (unchanged layout, neutralized palette) --- */ + /* raw tab markdown (unchanged layout, neutralized palette) */ .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; } .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; } .md h1 { font-size: 18px; } @@ -8303,7 +9659,7 @@ void main() { return box; } - // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 --- + // Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 function renderDesignVisual(body, parsed, sidecar) { const frontmatter = parsed?.frontmatter || {}; @@ -8667,7 +10023,7 @@ void main() { return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components'); } - // --- Collapsibles --------------------------------------------------------- + // Collapsibles. function buildCollapsible(key, label, count) { const wrap = document.createElement('div'); @@ -8775,7 +10131,7 @@ void main() { return s.replace(/\s+#.*$/, '').trim(); } - // --- Raw tab: minimal markdown renderer (subset) -------------------------- + // Raw tab: minimal markdown renderer (subset) function renderRawTab(body, md) { const wrap = document.createElement('div'); @@ -8908,9 +10264,9 @@ void main() { } catch { /* ignore */ } } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // function init() { try { history.scrollRestoration = 'manual'; } catch {} diff --git a/.github/skills/impeccable/scripts/live-completion.mjs b/.github/skills/impeccable/scripts/live-completion.mjs index 86b637fff..986773066 100644 --- a/.github/skills/impeccable/scripts/live-completion.mjs +++ b/.github/skills/impeccable/scripts/live-completion.mjs @@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.mode === 'error') return 'error'; + if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error'; return 'agent_done'; } diff --git a/.github/skills/impeccable/scripts/live-inject.mjs b/.github/skills/impeccable/scripts/live-inject.mjs index b9d3df41d..3a1f36e46 100644 --- a/.github/skills/impeccable/scripts/live-inject.mjs +++ b/.github/skills/impeccable/scripts/live-inject.mjs @@ -17,11 +17,38 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './impeccable-paths.mjs'; +import { + applySvelteKitLiveAdapter, + detectSvelteKitProject, + removeSvelteKitLiveAdapter, +} from './live-sveltekit-adapter.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start'; +const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end'; + +export const LIVE_IGNORE_PATTERNS = Object.freeze([ + '.impeccable/hook.cache.json', + '.impeccable/live/server.json', + '.impeccable/live/sessions/', + '.impeccable/live/previews/', + '.impeccable/live/annotations/', + '.impeccable/live/cache/', + '.impeccable/live/manual-edit-apply-transaction.json', + '.impeccable/live/manual-edit-events.jsonl', + '.impeccable/live/manual-edit-evidence/', + '.impeccable/live/pending-manual-edits.json', + '.impeccable/live/deferred-svelte-component-accepts.json', + '.impeccable-live.json', + '.impeccable-live/', + 'node_modules/.impeccable-live/', + 'src/lib/impeccable/ImpeccableLiveRoot.svelte', + 'src/lib/impeccable/__runtime.js', + 'src/lib/impeccable/[0-9a-f]*/', +]); /** * Hard-excluded directory patterns. These are NEVER user-facing pages and @@ -83,8 +110,14 @@ Output (JSON): validateConfig(config); const resolvedFiles = resolveFiles(process.cwd(), config); + const svelteKit = detectSvelteKitProject(process.cwd(), config); if (args.includes('--remove')) { + if (svelteKit) { + const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config }); + console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; @@ -110,6 +143,13 @@ Output (JSON): console.error(JSON.stringify({ ok: false, error: 'missing_port' })); process.exit(1); } + const gitIgnore = ensureLiveGitIgnores(process.cwd()); + + if (svelteKit) { + const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config }); + console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); @@ -129,10 +169,68 @@ Output (JSON): }; }); const anyInserted = results.some((r) => r.inserted); - console.log(JSON.stringify({ ok: anyInserted, port, results })); + console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results })); if (!anyInserted) process.exit(1); } +export function ensureLiveGitIgnores(cwd = process.cwd()) { + const target = resolveIgnoreTarget(cwd); + const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : ''; + const block = [ + IGNORE_MARKER_OPEN, + ...LIVE_IGNORE_PATTERNS, + IGNORE_MARKER_CLOSE, + ].join('\n'); + const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`); + + let updated; + if (markerRe.test(existing)) { + updated = existing.replace(markerRe, block); + } else { + const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n'; + updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`; + } + + if (updated !== existing) { + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + fs.writeFileSync(target.path, updated, 'utf-8'); + } + + return { + file: path.relative(cwd, target.path).split(path.sep).join('/'), + mode: target.mode, + changed: updated !== existing, + patterns: [...LIVE_IGNORE_PATTERNS], + }; +} + +function resolveIgnoreTarget(cwd) { + const gitExcludePath = resolveGitInfoExcludePath(cwd); + if (gitExcludePath) { + return { path: gitExcludePath, mode: 'git-info-exclude' }; + } + return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' }; +} + +function resolveGitInfoExcludePath(cwd) { + const dotGit = path.join(cwd, '.git'); + if (!fs.existsSync(dotGit)) return null; + + const stat = fs.statSync(dotGit); + if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude'); + if (!stat.isFile()) return null; + + const body = fs.readFileSync(dotGit, 'utf-8').trim(); + const match = body.match(/^gitdir:\s*(.+)$/i); + if (!match) return null; + const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]); + return path.join(gitDir, 'info', 'exclude'); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Expand config.files (which may contain glob patterns) into a literal list * of existing file paths relative to rootDir. Literal entries pass through; diff --git a/.github/skills/impeccable/scripts/live-insert.mjs b/.github/skills/impeccable/scripts/live-insert.mjs index 09d4d55be..0658e9914 100644 --- a/.github/skills/impeccable/scripts/live-insert.mjs +++ b/.github/skills/impeccable/scripts/live-insert.mjs @@ -21,6 +21,11 @@ import { buildCssAuthoring, buildCssSelectorPrefixExamples, } from './live-wrap.mjs'; +import { + buildSvelteComponentCssAuthoring, + scaffoldSvelteComponentInsertSession, + shouldUseSvelteComponentInjection, +} from './live-svelte-component.mjs'; const INSERT_POSITIONS = new Set(['before', 'after']); @@ -192,6 +197,41 @@ Output (JSON): const styleMode = detectStyleMode(targetFile); const isJsx = commentSyntax.open === '{/*'; const spliceIndex = computeInsertLine(startLine, endLine, position); + const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); + + if (shouldUseSvelteComponentInjection(targetFile)) { + const session = scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile: relTargetFile, + insertLine: spliceIndex + 1, + position, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + anchorLines: lines.slice(startLine, endLine + 1), + cwd: process.cwd(), + }); + console.log(JSON.stringify({ + mode: 'insert', + position, + file: session.manifestFile, + sourceFile: relTargetFile, + previewMode: 'svelte-component', + componentDir: session.componentDir, + propContract: session.propContract, + insertLine: 1, + sourceInsertLine: spliceIndex + 1, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + commentSyntax, + styleMode: 'svelte-component', + styleTag: null, + cssSelectorPrefixExamples: [], + cssAuthoring: buildSvelteComponentCssAuthoring(count), + })); + return; + } + const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? ''; @@ -216,7 +256,7 @@ Output (JSON): console.log(JSON.stringify({ mode: 'insert', position, - file: path.relative(process.cwd(), targetFile), + file: relTargetFile, insertLine: insertLine + 1, commentSyntax, styleMode: styleMode.mode, diff --git a/.github/skills/impeccable/scripts/live-poll.mjs b/.github/skills/impeccable/scripts/live-poll.mjs index fad836612..1e1259fbc 100644 --- a/.github/skills/impeccable/scripts/live-poll.mjs +++ b/.github/skills/impeccable/scripts/live-poll.mjs @@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs'; // that ceiling and loop in `pollOnce` to synthesize a long poll without // depending on the standalone undici package. export const PER_REQUEST_TIMEOUT_MS = 270_000; +export const DEFAULT_EVENT_LEASE_MS = 600_000; const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); @@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) { ? totalDeadline - Date.now() : PER_REQUEST_TIMEOUT_MS; const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); - const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); + const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`); if (res.status === 401) { const err = new Error('Authentication failed. The server token may have changed.'); @@ -317,7 +318,7 @@ Modes: Options: --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) - --file PATH Attach a source file path to the reply (generate flow) + --file PATH Attach a source file path to the reply (generate/steer flow) --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --help Show this help message diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs index 16c8285b9..cd1091b88 100644 --- a/.github/skills/impeccable/scripts/live-server.mjs +++ b/.github/skills/impeccable/scripts/live-server.mjs @@ -42,6 +42,10 @@ import { } from './live-manual-edits-buffer.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs'; +import { + applyDeferredSvelteComponentAccepts, + removeAllSvelteComponentSessions, +} from './live-svelte-component.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated @@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; +const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2; const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); function tombstoneTimedOutApplyId(eventId, details = {}) { @@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) { return entry.event; } entry.leaseUntil = Date.now() + leaseMs; + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return entry.event; } @@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) { const acknowledged = state.pendingEvents[idx].event; state.pendingEvents.splice(idx, 1); scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return acknowledged; } +function findPendingEventById(id) { + if (!id) return null; + const entry = state.pendingEvents.find((item) => item.event?.id === id); + return entry?.event || null; +} + function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; return `live-poll.mjs --reply ${id} done --data ''`; @@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) { return summary; } +function summarizeActiveSessionForClient(snapshot = {}) { + return { + id: snapshot.id, + phase: snapshot.phase, + pageUrl: snapshot.pageUrl ?? null, + sourceFile: snapshot.sourceFile ?? null, + previewFile: snapshot.previewFile ?? null, + previewMode: snapshot.previewMode ?? null, + expectedVariants: snapshot.expectedVariants ?? 0, + arrivedVariants: snapshot.arrivedVariants ?? 0, + visibleVariant: snapshot.visibleVariant ?? null, + checkpointRevision: snapshot.checkpointRevision ?? 0, + paramValues: snapshot.paramValues || {}, + }; +} + +function activeSessionSummaries() { + if (!state.sessionStore) return []; + return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot)); +} + +function cancelQueuedAnonymousExitEvents() { + let removed = 0; + for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) { + const event = state.pendingEvents[i]?.event; + if (event?.type !== 'exit' || event.id) continue; + state.pendingEvents.splice(i, 1); + removed += 1; + } + if (removed > 0) { + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); + } + return removed; +} + function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { const canceledById = new Map(); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); @@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() { clearTimeout(state.leaseTimer); state.leaseTimer = null; } - if (state.pendingPolls.length === 0) return; const now = Date.now(); const nextLeaseUntil = state.pendingEvents .map((entry) => entry.leaseUntil || 0) @@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() { state.leaseTimer = setTimeout(() => { state.leaseTimer = null; flushPendingPolls(); - }, Math.max(0, nextLeaseUntil - now)); + broadcastAgentPollingIfChanged(); + }, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS)); } function flushPendingPolls() { @@ -1032,7 +1082,9 @@ function flushPendingPolls() { } function agentPollingConnected() { - return state.pendingPolls.length > 0; + const now = Date.now(); + return state.pendingPolls.length > 0 + || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now); } function broadcastAgentPollingIfChanged() { @@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/status') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } - const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; + const sessions = activeSessionSummaries(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', @@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/events' && req.method === 'GET') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + clearTimeout(state.exitTimer); + state.exitTimer = null; + cancelQueuedAnonymousExitEvents(); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { type: 'connected', hasProjectContext: hasProjectContext(), agentPolling: agentPollingConnected(), + activeSessions: activeSessionSummaries(), }) + '\n\n'); state.sseClients.add(res); - clearTimeout(state.exitTimer); // Keepalive: SSE comment every 30s prevents silent connection drops. const heartbeat = setInterval(() => { @@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } } + if (msg.type === 'exit') { + cleanupSvelteComponentSessionsBeforeExit(); + } if (msg.type !== 'checkpoint') { enqueueEvent(msg); } @@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) { }); } +function sessionFileMetadataFromPollReply(file) { + if (!file || typeof file !== 'string') return { file }; + const normalized = file.split(path.sep).join('/'); + const base = { file: normalized }; + if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base; + if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base; + + let full; + try { + full = path.resolve(process.cwd(), normalized); + const rel = path.relative(process.cwd(), full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base; + } catch { + return base; + } + + try { + const manifest = JSON.parse(fs.readFileSync(full, 'utf-8')); + if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base; + return { + file: String(manifest.sourceFile).split(path.sep).join('/'), + sourceFile: String(manifest.sourceFile).split(path.sep).join('/'), + previewFile: normalized, + previewMode: 'svelte-component', + }; + } catch { + return base; + } +} + function handlePollPost(req, res) { let body = ''; req.on('data', (c) => { body += c; }); @@ -1965,6 +2053,16 @@ function handlePollPost(req, res) { res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); return; } + const pendingEventBeforeAck = findPendingEventById(msg.id); + if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done' + && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'steer_done_requires_file_or_message', + hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.', + })); + return; + } const acknowledgedEvent = acknowledgePendingEvent(msg.id); let skipJournalReply = false; let existingSession = null; @@ -1987,6 +2085,7 @@ function handlePollPost(req, res) { })); return; } + const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); if (state.sessionStore && msg.id && !skipJournalReply) { try { const eventType = msg.type === 'steer_done' @@ -2001,7 +2100,10 @@ function handlePollPost(req, res) { state.sessionStore.appendEvent({ type: eventType, id: msg.id, - file: msg.file, + file: replyFileMeta.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, message: msg.message, sourceEventType: acknowledgedEvent?.type, carbonize: msg.data?.carbonize === true, @@ -2010,7 +2112,16 @@ function handlePollPost(req, res) { } flushPendingPolls(); // Forward the reply to the browser via SSE - broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + broadcast({ + type: msg.type || 'done', + id: msg.id, + message: msg.message, + file: msg.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, + data: msg.data, + }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); @@ -2023,6 +2134,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { + cleanupSvelteComponentSessionsBeforeExit(); removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; @@ -2037,6 +2149,25 @@ function shutdown() { process.exit(0); } +function cleanupSvelteComponentSessionsBeforeExit() { + try { + removeAllSvelteComponentSessions(process.cwd()); + } catch (err) { + console.warn('[impeccable] Svelte component session cleanup failed:', err.message); + } +} + +function applyLegacyDeferredAcceptsOnStartup() { + try { + const result = applyDeferredSvelteComponentAccepts(process.cwd()); + if (result.applied > 0 || result.failed > 0) { + console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result)); + } + } catch (err) { + console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({ cwd: process.cwd(), reason: 'manual_edit_server_start_recovered_abandoned_transaction', }); +applyLegacyDeferredAcceptsOnStartup(); restorePendingEventsFromStore(); pruneStaleManualApplyEvidence(process.cwd()); const portArg = args.find(a => a.startsWith('--port=')); diff --git a/.github/skills/impeccable/scripts/live-session-store.mjs b/.github/skills/impeccable/scripts/live-session-store.mjs index 7562e3d5b..5ec4d34d4 100644 --- a/.github/skills/impeccable/scripts/live-session-store.mjs +++ b/.github/skills/impeccable/scripts/live-session-store.mjs @@ -106,6 +106,8 @@ function baseSnapshot(id) { phase: 'new', pageUrl: null, sourceFile: null, + previewFile: null, + previewMode: null, expectedVariants: 0, arrivedVariants: 0, visibleVariant: null, @@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; - next.sourceFile = event.file ?? next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0); next.pendingEventSeq = null; next.pendingEvent = null; if (event.carbonize === true) { @@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { } break; case 'checkpoint': + if (COMPLETED_PHASES.has(next.phase)) { + next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null }); + break; + } if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { next.phase = event.phase ?? next.phase; next.checkpointRevision = event.revision ?? next.checkpointRevision; next.activeOwner = event.owner ?? next.activeOwner; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.visibleVariant = event.visibleVariant ?? next.visibleVariant; + next.sourceFile = event.sourceFile ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; if (event.paramValues) next.paramValues = { ...event.paramValues }; } else { next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); @@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'steer_done': next.phase = 'steer_done'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.message = event.message ?? next.message; next.pendingEventSeq = null; next.pendingEvent = null; break; @@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'complete': next.phase = 'completed'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; next.pendingEventSeq = null; next.pendingEvent = null; break; diff --git a/.github/skills/impeccable/scripts/live-svelte-component.mjs b/.github/skills/impeccable/scripts/live-svelte-component.mjs new file mode 100644 index 000000000..dc35dc0ce --- /dev/null +++ b/.github/skills/impeccable/scripts/live-svelte-component.mjs @@ -0,0 +1,826 @@ +/** + * Svelte live-mode component injection helpers. + * + * Variants are real .svelte components under node_modules/.impeccable-live//. + * The browser mounts them via Svelte 5 mount(); accept inlines the chosen + * variant back into the route source with props mapped to original bindings. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; + +export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live'; +export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`; +export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json'; + +const MUSTACHE_RE = /\{([^{}]+)\}/g; + +export function shouldUseSvelteComponentInjection(filePath) { + if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false; + return path.extname(filePath).toLowerCase() === '.svelte'; +} + +export function componentSessionDir(id, cwd = process.cwd()) { + return path.join(cwd, SVELTE_COMPONENT_ROOT, id); +} + +export function manifestPathForSession(id, cwd = process.cwd()) { + return path.join(componentSessionDir(id, cwd), 'manifest.json'); +} + +export function ensureRuntimeHelper(cwd = process.cwd()) { + const file = path.join(cwd, SVELTE_RUNTIME_FILE); + if (fs.existsSync(file)) return file; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8'); + return file; +} + +/** + * Extract ordered unique mustache expressions from markup (not inside ). + */ +export function extractMustacheExpressions(text) { + const expressions = []; + const seen = new Set(); + const lines = String(text || '').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('\n` + : ''; + return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n\n`; +} + +function buildInsertVariantStub(variantNum) { + return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; +} + +export function scaffoldSvelteComponentSession({ + id, + count, + sourceFile, + sourceStartLine, + sourceEndLine, + originalLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const originalMarkup = originalLines.join('\n'); + const contract = buildPropContract(extractMustacheExpressions(originalMarkup)); + const originalWithProps = substituteExprsWithProps(originalMarkup, contract); + + const manifest = { + id, + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + sourceStartLine, + sourceEndLine, + count, + propContract: contract, + originalMarkup, + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: contract, + }; +} + +export function scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile, + insertLine, + position, + anchorStartLine, + anchorEndLine, + anchorLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const anchorMarkup = (anchorLines || []).join('\n'); + const manifest = { + id, + mode: 'insert', + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + insertLine, + position, + anchorStartLine, + anchorEndLine, + originalMarkup: anchorMarkup, + anchorMarkup, + count, + propContract: [], + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: [], + }; +} + +export function findSvelteComponentManifest(id, cwd = process.cwd()) { + const direct = manifestPathForSession(id, cwd); + if (fs.existsSync(direct)) { + return readManifest(direct); + } + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return null; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(root, entry.name, 'manifest.json'); + if (!fs.existsSync(candidate)) continue; + try { + const manifest = readManifest(candidate); + if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; + } catch { /* skip */ } + } + return null; +} + +export function readManifest(manifestPath) { + const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + return { + ...data, + manifestPath, + }; +} + +export function resolveSourceFile(sourceFile, cwd = process.cwd()) { + if (!sourceFile || path.isAbsolute(sourceFile)) { + throw new Error('Invalid svelte-component source file'); + } + const full = path.resolve(cwd, sourceFile); + const rel = path.relative(cwd, full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('Svelte-component source file escapes project root'); + } + if (!fs.existsSync(full)) { + throw new Error('Svelte-component source file not found: ' + sourceFile); + } + return full; +} + +function appendCssToSvelteStyle(lines, cssLines) { + const closeIdx = findLastStyleCloseLine(lines); + const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; + if (closeIdx === -1) { + return [...lines, '', '']; + } + return [ + ...lines.slice(0, closeIdx), + ...prepared, + ...lines.slice(closeIdx), + ]; +} + +function findLastStyleCloseLine(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + if (/<\/style\s*>/.test(lines[i])) return i; + } + return -1; +} + +function bakeParamValuesInCss(cssLines, paramValues) { + if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; + return cssLines.map((line) => { + let out = line; + for (const [key, value] of Object.entries(paramValues)) { + const varName = `--p-${key}`; + out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); + } + return out; + }); +} + +function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { + const css = String((cssLines || []).join('\n')); + if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; + + const rules = parseCssRules(css); + const output = []; + for (const rule of rules) { + appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); + } + return output.join('\n') + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ''); +} + +function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { + const prelude = rule.prelude.trim(); + const body = rule.body.trim(); + if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; + + if (/^@scope\b/i.test(prelude)) { + if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; + const inner = parseCssRules(body); + for (const innerRule of inner) { + const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); + if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; + output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); + } + return; + } + + const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); + if (!rewrittenPrelude) return; + output.push(formatCssRule(rewrittenPrelude, body)); +} + +function parseCssRules(css) { + const rules = []; + const text = String(css || ''); + let i = 0; + while (i < text.length) { + while (i < text.length && /\s/.test(text[i])) i++; + const preludeStart = i; + while (i < text.length && text[i] !== '{') i++; + if (i >= text.length) break; + const prelude = text.slice(preludeStart, i).trim(); + i++; + const bodyStart = i; + let depth = 1; + let quote = null; + let comment = false; + while (i < text.length && depth > 0) { + const ch = text[i]; + const next = text[i + 1]; + if (comment) { + if (ch === '*' && next === '/') { + comment = false; + i += 2; + continue; + } + i++; + continue; + } + if (quote) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === quote) quote = null; + i++; + continue; + } + if (ch === '/' && next === '*') { + comment = true; + i += 2; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); + if (prelude) rules.push({ prelude, body }); + } + return rules; +} + +function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { + const selectors = splitSelectorList(prelude); + const rewritten = []; + for (const selector of selectors) { + const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); + if (next) rewritten.push(next); + } + return rewritten.join(', '); +} + +function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { + let out = selector.trim(); + const hasVariant = /data-impeccable-variant/.test(out); + if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; + if (hasVariant) { + out = out.replace(variantSelectorRegex(variantNum), ''); + out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); + } + + const paramResult = rewriteParamSelectors(out, paramValues); + if (!paramResult.keep) return ''; + out = paramResult.selector; + + out = out + .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') + .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') + .replace(/\s+/g, ' ') + .trim(); + + out = out.replace(/^[>+~]\s*/, '').trim(); + if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; + return out; +} + +function rewriteParamSelectors(selector, paramValues) { + let keep = true; + const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { + if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; + const actual = paramValues[key]; + if (expected != null && String(actual) !== String(expected)) { + keep = false; + return ''; + } + if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { + keep = false; + return ''; + } + return ''; + }); + return { keep, selector: next }; +} + +function splitSelectorList(prelude) { + const selectors = []; + let start = 0; + let bracket = 0; + let paren = 0; + let quote = null; + for (let i = 0; i < prelude.length; i++) { + const ch = prelude[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '[') bracket++; + else if (ch === ']') bracket = Math.max(0, bracket - 1); + else if (ch === '(') paren++; + else if (ch === ')') paren = Math.max(0, paren - 1); + else if (ch === ',' && bracket === 0 && paren === 0) { + selectors.push(prelude.slice(start, i)); + start = i + 1; + } + } + selectors.push(prelude.slice(start)); + return selectors; +} + +function selectorHasVariant(selector, variantNum) { + return variantSelectorRegex(variantNum).test(selector); +} + +function variantSelectorRegex(variantNum) { + return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); +} + +function formatCssRule(selector, body) { + return `${selector} { ${body.trim()} }`; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { + const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); + const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); + const resultBase = { + file: manifest.sourceFile, + sourceFile: manifest.sourceFile, + previewMode: 'svelte-component', + componentDir: manifest.componentDir, + carbonize: false, + }; + if (!fs.existsSync(variantPath)) { + return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; + } + + const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); + if (manifest.mode === 'insert') { + return inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, + }); + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const contract = manifest.propContract || []; + const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); + const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract) + .split('\n') + .map((line) => line.trimEnd()); + + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const start = Number(manifest.sourceStartLine) - 1; + const end = Number(manifest.sourceEndLine) - 1; + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { + return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; + } + + const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, start), + ...indentedMarkup, + ...sourceLines.slice(end + 1), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, +}) { + if (!svelteMarkupHasVisibleContent(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; + } + if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const restoredMarkup = String(markup || '') + .split('\n') + .map((line) => line.trimEnd()); + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const insertIndex = Number(manifest.insertLine) - 1; + if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { + return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; + } + + const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; + const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, insertIndex), + ...indentedMarkup, + ...sourceLines.slice(insertIndex), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function svelteMarkupHasVisibleContent(markup) { + const text = String(markup || '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 0) return true; + return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); +} + +function mergeOriginalTopLevelAttrs(markup, originalMarkup) { + const variantOpen = matchOpeningTag(markup); + const originalOpen = matchOpeningTag(originalMarkup); + if (!variantOpen || !originalOpen) return markup; + if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; + + const variantAttrs = parseAttrSegments(variantOpen.attrs); + const originalAttrs = parseAttrSegments(originalOpen.attrs); + const additions = []; + let attrs = variantOpen.attrs; + + const originalClass = originalAttrs.get('class'); + const variantClass = variantAttrs.get('class'); + if (originalClass && variantClass) { + const merged = mergeStaticClassAttr(originalClass, variantClass); + if (merged) { + attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); + variantAttrs.set('class', { ...variantClass, raw: merged }); + } + } else if (originalClass && !variantClass) { + additions.push(originalClass.raw); + } + + for (const [name, attr] of originalAttrs) { + if (name === 'class') continue; + if (!variantAttrs.has(name)) additions.push(attr.raw); + } + + if (additions.length === 0 && attrs === variantOpen.attrs) return markup; + const nextOpen = variantOpen.prefix + + variantOpen.tag + + attrs + + additions.map((attr) => ' ' + attr.trim()).join('') + + variantOpen.close; + return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); +} + +function matchOpeningTag(markup) { + const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); + if (!match) return null; + return { + raw: match[0], + prefix: match[1], + tag: match[2], + attrs: match[3] || '', + close: match[4], + index: match.index || 0, + }; +} + +function parseAttrSegments(attrs) { + const out = new Map(); + const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; + let match; + while ((match = re.exec(attrs))) { + const raw = match[0]; + const name = match[1]; + out.set(name, { + name, + raw, + start: match.index, + end: match.index + raw.length, + }); + } + return out; +} + +function mergeStaticClassAttr(originalClass, variantClass) { + const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + if (!originalValue || !variantValue) return null; + const quote = variantValue[1]; + const classes = [ + ...variantValue[2].split(/\s+/), + ...originalValue[2].split(/\s+/), + ].filter(Boolean); + return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; +} + +export function removeSvelteComponentSession(id, cwd = process.cwd()) { + const dir = componentSessionDir(id, cwd); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* non-fatal */ } +} + +export function removeAllSvelteComponentSessions(cwd = process.cwd()) { + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('__')) continue; + try { + fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + } catch { /* non-fatal */ } + } +} + +export function deferredAcceptsPath(cwd = process.cwd()) { + const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16); + return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json'); +} + +export function readDeferredAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch { + return { accepts: [] }; + } +} + +export function writeDeferredAccept(entry, cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const data = readDeferredAccepts(cwd); + data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id); + data.accepts.push({ ...entry, createdAt: new Date().toISOString() }); + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + const data = readDeferredAccepts(cwd); + const pending = Array.isArray(data.accepts) ? data.accepts : []; + const results = []; + const remaining = []; + for (const entry of pending) { + try { + const manifest = findSvelteComponentManifest(entry.id, cwd); + if (!manifest) { + results.push({ id: entry.id, ok: false, error: 'manifest not found' }); + remaining.push(entry); + continue; + } + const result = inlineSvelteComponentAccept( + manifest, + entry.variantNum, + entry.paramValues || null, + cwd, + ); + results.push({ id: entry.id, ok: result.handled !== false, result }); + if (result.handled === false) remaining.push(entry); + } catch (err) { + results.push({ id: entry.id, ok: false, error: err.message }); + remaining.push(entry); + } + } + if (remaining.length > 0) { + fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8'); + } else { + try { fs.rmSync(file, { force: true }); } catch {} + } + return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results }; +} + +export function buildSvelteComponentCssAuthoring(count) { + const variantNumbers = Array.from({ length: count }, (_, i) => i + 1); + return { + mode: 'svelte-component', + styleTag: null, + strategy: 'component-style-block', + rulePattern: '.semantic-class { ... }', + selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'), + requirements: [ + 'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).', + 'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.', + 'Put variant CSS in the component ' : '')); + if (paramValues && Object.keys(paramValues).length > 0) { + lines.push( + bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close, + ); + } + lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + lines.push(bodyIndent + '
'); + lines.push(...bodyRestored); + lines.push(bodyIndent + '
'); + }; + + if (isJsx) { + const wrapperStyle = 'style={{ display: "contents" }}'; + lines.push(indent + '
'); + pushCarbonizeBody(indent + ' '); + lines.push(indent + '
'); + } else { + pushCarbonizeBody(indent); + } + + return lines; +} + +function reindentContent(contentLines, fromIndent, toIndent) { + return contentLines.map((line) => { + if (line.trim() === '') return ''; + if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length); + return toIndent + line.trimStart(); + }); +} + function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; @@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const needsCarbonize = !!(cssContent || hasHelperAttrs); - // Build the replacement const restored = deindentContent(variantContent, indent); - const replacement = []; - - if (cssContent) { - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); - // JSX targets need the CSS body wrapped in a template literal so that the - // `{` and `}` in CSS rules don't get parsed as JSX expressions. - replacement.push(indent + '' : '')); - if (paramValues && Object.keys(paramValues).length > 0) { - // Preserve the user's knob positions for the carbonize-cleanup agent - // to bake into the final CSS when it collapses scoped rules. - replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); - } - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); - } - - // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the - // carbonize CSS block working visually by re-wrapping the accepted content - // in a data-impeccable-variant="N" div with `display: contents` (so layout - // isn't affected). The carbonize agent strips this attribute + wrapper when - // it moves the CSS to a proper stylesheet. - // - // Style attribute syntax has to follow the host file's flavor — JSX files - // need the object form, otherwise React 19 throws "Failed to set indexed - // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. - if (cssContent) { - const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; - replacement.push(indent + '
'); - replacement.push(...restored); - replacement.push(indent + '
'); - } else { - replacement.push(...restored); - } + const replacement = buildCarbonizeReplacement({ + indent, + commentSyntax, + isJsx, + id, + variantNum, + cssContent, + paramValues, + restored, + }); const newLines = [ ...lines.slice(0, replaceRange.start), @@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; } +function readSourceShadowPreviewMeta(content, id) { + const escaped = escapeRegExp(id); + const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>'); + const match = String(content || '').match(wrapperRe); + if (!match) return null; + const tag = match[0]; + if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null; + const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file'); + const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start')); + const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end')); + if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null; + return { sourceFile, sourceStartLine, sourceEndLine }; +} + +function readHtmlAttr(tag, name) { + const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1')); + if (!match) return null; + return decodeHtmlAttr(match[2]); +} + +function decodeHtmlAttr(value) { + return String(value || '') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- @@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts }; diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index f609ac847..d7c580e2f 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -26,9 +26,9 @@ return; } - // --------------------------------------------------------------------------- + // // Design tokens - // --------------------------------------------------------------------------- + // // Brand kinpaku (gold) is pinned to the site's neo-kinpaku tokens // (see site/styles/kinpaku-tokens.css) so Accept / knobs / cycle-dots / @@ -115,19 +115,48 @@ { value: 'overdrive', label: 'Overdrive' }, ]; - // --------------------------------------------------------------------------- + const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions']; + const LIVE_UI_SURFACES = [ + { 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'] }, + { key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] }, + { key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-configure-input-wrap', PREFIX + '-input', PREFIX + '-configure-voice'] }, + { key: 'action-picker', ids: [PREFIX + '-picker'] }, + { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, + { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, + { key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] }, + { key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] }, + { key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] }, + { 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'] }, + { key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] }, + { key: 'design-system-panel', ids: [PREFIX + '-design-host'] }, + { key: 'toasts-and-errors', ids: [PREFIX + '-toast'] }, + { key: 'css-isolation-boundary', ids: [PREFIX + '-root'] }, + ]; + const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))]; + + // // State - // --------------------------------------------------------------------------- + // let state = 'IDLE'; let hoveredElement = null; let selectedElement = null; let currentSessionId = null; - let pendingAcceptedSession = null; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; + let svelteComponentSession = null; + let svelteRuntimePromise = null; + let pendingSvelteComponentRetryObserver = null; + let currentSourceFile = null; + let currentPreviewFile = null; + let currentPreviewMode = null; + let recoveryWaitingForAnchor = false; + let pendingAcceptedSession = null; let variantObserver = null; + let variantSelectionInFlight = false; + let variantSelectionPromise = null; + let recoveringEmptyCycling = false; let hasProjectContext = false; let selectedAction = 'impeccable'; let selectedCount = 3; @@ -175,14 +204,17 @@ let highlightEl = null; let tooltipEl = null; let barEl = null; + let barHideSeq = 0; let pickerEl = null; let toastEl = null; let scrollRaf = null; let editBadgeEl = null; + let editBadgeProxyRoot = null; + let editBadgeProxyByTarget = new Map(); - // --------------------------------------------------------------------------- + // // Helpers - // --------------------------------------------------------------------------- + // function own(el) { return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); @@ -204,8 +236,105 @@ return s; } + function rectIsUsableAnchor(rect) { + return !!rect && rect.width > 0.5 && rect.height > 0.5; + } + + function makeFrozenAnchor(el) { + if (!el || !el.getBoundingClientRect) return null; + const r = el.getBoundingClientRect(); + if (!rectIsUsableAnchor(r)) return null; + const rect = { + x: r.x, y: r.y, + top: r.top, left: r.left, + right: r.right, bottom: r.bottom, + width: r.width, height: r.height, + }; + return { + __impeccableFrozenAnchor: true, + tagName: el.tagName || 'DIV', + id: el.id || '', + classList: el.classList ? [...el.classList] : [], + hasAttribute: () => false, + getBoundingClientRect: () => rect, + }; + } + function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } + function cssId(id) { + if (window.CSS?.escape) return CSS.escape(id); + return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); + } + + function liveUiRoot() { + const root = window.__IMPECCABLE_LIVE_UI_ROOT__; + if (root && typeof root.appendChild === 'function') return root; + return document.body; + } + + function uiAppend(el) { + liveUiRoot().appendChild(el); + return el; + } + + function uiAppendStyle(styleEl) { + const root = liveUiRoot(); + if (root && root !== document.body) root.appendChild(styleEl); + else document.head.appendChild(styleEl); + return styleEl; + } + + function uiGetById(id) { + const root = liveUiRoot(); + if (root?.getElementById) { + const found = root.getElementById(id); + if (found) return found; + } + if (root?.querySelector) { + const found = root.querySelector('#' + cssId(id)); + if (found) return found; + } + return document.getElementById(id); + } + + function activeElementDeep() { + let active = document.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; + return active; + } + + window.__IMPECCABLE_LIVE_CHROME_CORE__ = { + version: 1, + adapter: window.__IMPECCABLE_LIVE_ADAPTER__ || 'dom', + mountContract: LIVE_CHROME_MOUNT_CONTRACT, + surfaces: LIVE_UI_SURFACES, + componentIds: LIVE_UI_COMPONENT_IDS, + root: liveUiRoot, + append: uiAppend, + appendStyle: uiAppendStyle, + getById: uiGetById, + activeElementDeep, + debugState: () => ({ + state, + currentSessionId, + expectedVariants, + arrivedVariants, + visibleVariant, + savedSession: loadSession(), + sourceFile: currentSourceFile, + previewFile: currentPreviewFile, + previewMode: currentPreviewMode, + barText: barEl?.textContent || null, + barConnected: !!barEl?.isConnected, + hasSvelteComponentSession: !!svelteComponentSession, + mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0, + pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver, + recoveryWaitingForAnchor, + evtSourceReadyState: evtSource ? evtSource.readyState : null, + }), + }; + // Modal-aware chrome: keep our floating UI clickable inside Radix / // Headless UI / vaul portals. // @@ -245,9 +374,9 @@ rootEl.addEventListener('focusin', stop); } - // --------------------------------------------------------------------------- + // // Highlight overlay - // --------------------------------------------------------------------------- + // function initHighlight() { highlightEl = document.createElement('div'); @@ -259,7 +388,7 @@ transition: HIGHLIGHT_TRANSITION, display: 'none', opacity: '0', }); - document.body.appendChild(highlightEl); + uiAppend(highlightEl); tooltipEl = document.createElement('div'); tooltipEl.id = PREFIX + '-tooltip'; @@ -273,7 +402,7 @@ letterSpacing: '0.02em', transition: TOOLTIP_TRANSITION, }); - document.body.appendChild(tooltipEl); + uiAppend(tooltipEl); } function showHighlight(el) { @@ -310,7 +439,7 @@ if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; } } - // --------------------------------------------------------------------------- + // // Annotation overlay (comment pins + kinpaku strokes) // // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned @@ -318,7 +447,7 @@ // drag) drops a comment pin; drag paints a kinpaku SVG stroke. All coords // are stored in element-local CSS px so they survive scroll / resize and // correlate directly with the captured PNG. - // --------------------------------------------------------------------------- + // const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it @@ -397,7 +526,7 @@ annotOverlayEl.addEventListener('pointermove', onAnnotMove); annotOverlayEl.addEventListener('pointerup', onAnnotUp); annotOverlayEl.addEventListener('pointercancel', onAnnotUp); - document.body.appendChild(annotOverlayEl); + uiAppend(annotOverlayEl); // Modal-host friendliness: pointer-events is already 'auto' on this // overlay; we only need to silence the host's outside-interaction // listeners. Don't override pointer-events here (the overlay toggles @@ -828,9 +957,9 @@ return wrap; } - // --------------------------------------------------------------------------- + // // Element context extraction - // --------------------------------------------------------------------------- + // function stripManualEditRuntimeState(root) { if (!root || root.nodeType !== 1) return; @@ -971,9 +1100,9 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } - // --------------------------------------------------------------------------- + // // The Bar - one floating element, three modes - // --------------------------------------------------------------------------- + // // Contextual-bar palette. Cached at init so every build*Row reads a // consistent set of colors; detectPageTheme runs once rather than on every @@ -1006,7 +1135,7 @@ padding: '6px', maxWidth: '520px', minWidth: '320px', }); - document.body.appendChild(barEl); + uiAppend(barEl); defangOutsideHandlers(barEl); } @@ -1041,6 +1170,8 @@ } function showBar(mode) { + barHideSeq += 1; + if (mode === 'cycling' && !ensureCyclingRenderable('show-bar')) return; barEl.innerHTML = ''; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); @@ -1058,11 +1189,12 @@ function hideBar() { if (!barEl) return; + const hideSeq = ++barHideSeq; stopVoice({ suppressSubmit: true }); if (configureKind === 'insert') clearInsertPicking(); barEl.style.opacity = '0'; barEl.style.transform = 'translateY(6px)'; - setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); + setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); if (state === 'EDITING') restoreInlineEditDrafts(); @@ -1071,6 +1203,7 @@ function updateBarContent(mode) { if (!barEl || barEl.style.display === 'none') return; + if (mode === 'cycling' && !ensureCyclingRenderable('update-bar')) return; barEl.innerHTML = ''; // Reset bar styling to the kinpaku picker palette barEl.style.background = BP.surface; @@ -1090,13 +1223,13 @@ syncPageChatFocus('update-bar-content'); } - // --- Configure row --- + // Configure row function syncConfigureInputChrome() { - const wrap = document.getElementById(PREFIX + '-configure-input-wrap'); - const input = document.getElementById(PREFIX + '-input'); + const wrap = uiGetById(PREFIX + '-configure-input-wrap'); + const input = uiGetById(PREFIX + '-input'); if (!wrap || !input) return; - const focused = document.activeElement === input; + const focused = activeElementDeep() === input; wrap.dataset.inputFocused = focused ? 'true' : 'false'; wrap.dataset.voiceListening = (voiceListening && voiceCtx?.mode === 'configure') ? 'true' : 'false'; wrap.style.borderColor = (voiceListening && voiceCtx?.mode === 'configure') @@ -1104,7 +1237,7 @@ : (focused ? BP.accentSoft : BP.hairline); } - // --- Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) --- + // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) function detectInsertAxisFromStyle(style) { const display = style?.display || 'block'; @@ -1377,7 +1510,7 @@ display: 'none', opacity: '0.9', }); - document.body.appendChild(insertLineEl); + uiAppend(insertLineEl); defangOutsideHandlers(insertLineEl); return insertLineEl; } @@ -1440,6 +1573,10 @@ /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { + if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor) return anchor; + } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (wrapper) { @@ -1557,6 +1694,11 @@ positionBar(); } + function showOrUpdateCyclingBar() { + if (barEl && barEl.style.display !== 'none') updateBarContent('cycling'); + else showBar('cycling'); + } + function buildPlaceholderResizeHandles() { if (!placeholderResizeLayerEl) return; placeholderResizeLayerEl.innerHTML = ''; @@ -1665,7 +1807,7 @@ } function isInsertCreateEnabled(btn) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); + btn = btn || uiGetById(PREFIX + '-insert-create'); return !!btn && btn.getAttribute('aria-disabled') !== 'true'; } @@ -1691,7 +1833,7 @@ lineHeight: '1.35', }); insertCreateTooltipEl.id = PREFIX + '-insert-create-tooltip'; - document.body.appendChild(insertCreateTooltipEl); + uiAppend(insertCreateTooltipEl); return insertCreateTooltipEl; } @@ -1723,8 +1865,8 @@ } function syncInsertCreateButton(btn, input) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); - input = input || document.getElementById(PREFIX + '-insert-input'); + btn = btn || uiGetById(PREFIX + '-insert-create'); + input = input || uiGetById(PREFIX + '-insert-input'); if (!btn || !input) return; const gate = insertCreateGateState(input); const ok = canCreateInsert(gate); @@ -1833,7 +1975,7 @@ voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!document.getElementById(PREFIX + '-configure-input-style')) { + if (!uiGetById(PREFIX + '-configure-input-style')) { const s = document.createElement('style'); s.id = PREFIX + '-configure-input-style'; s.textContent = @@ -1842,7 +1984,7 @@ '#' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: impeccable-configure-voice-pulse 1.1s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-configure-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } input.addEventListener('focus', () => syncConfigureInputChrome()); @@ -1949,6 +2091,9 @@ transition: 'border-color 0.15s ease', }); inputWrap.id = PREFIX + '-insert-input-wrap'; + inputWrap.addEventListener('pointerdown', (e) => e.stopPropagation()); + inputWrap.addEventListener('mousedown', (e) => e.stopPropagation()); + inputWrap.addEventListener('click', (e) => e.stopPropagation()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; @@ -1984,6 +2129,12 @@ voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; input.addEventListener('input', () => syncInsertCreateButton()); + input.addEventListener('pointerdown', (e) => e.stopPropagation()); + input.addEventListener('mousedown', (e) => e.stopPropagation()); + input.addEventListener('click', (e) => { + e.stopPropagation(); + try { input.focus({ preventScroll: true }); } catch { input.focus(); } + }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); @@ -2049,6 +2200,7 @@ }); create.addEventListener('mouseleave', hideInsertCreateTooltip); create.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); if (controlsLocked) { showManualApplyBusyToast(); return; } if (!isInsertCreateEnabled(create)) return; @@ -2060,7 +2212,7 @@ return row; } - // --- Generating row --- + // Generating row function buildGeneratingRow() { const row = el('div', { @@ -2086,19 +2238,24 @@ }); // Variants currently arrive atomically in a single file edit, so a // per-variant counter would lie. Say what's true. - status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + expectedVariants + ' variants...' - : 'Done'; + status.textContent = recoveryWaitingForAnchor + ? 'Variants ready. Reveal the selected element to resume.' + : (arrivedVariants < expectedVariants + ? 'Generating ' + expectedVariants + ' variants...' + : 'Done'); row.appendChild(status); return row; } - // --- Cycling row --- + // Cycling row const TUNE_ICON_SVG = ''; function buildCyclingRow() { + if (!ensureCyclingRenderable('build-cycling-row')) { + return el('div', { display: 'none' }); + } const row = el('div', { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 2px', @@ -2106,6 +2263,7 @@ // Prev const prev = navBtn('\u2190'); + prev.id = PREFIX + '-variant-prev'; prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); }); if (visibleVariant <= 1) prev.style.opacity = '0.3'; row.appendChild(prev); @@ -2118,11 +2276,13 @@ fontFamily: MONO, fontSize: '11px', fontWeight: '500', color: BP.textDim, minWidth: '24px', textAlign: 'center', }); + counter.id = PREFIX + '-variant-counter'; counter.textContent = visibleVariant + '/' + arrivedVariants; row.appendChild(counter); // Next const next = navBtn('\u2192'); + next.id = PREFIX + '-variant-next'; next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); }); if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3'; row.appendChild(next); @@ -2208,9 +2368,9 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders - // --- Saving row (waiting for agent to process accept/discard) --- + // Saving row (waiting for agent to process accept/discard) function buildSavingRow() { const row = el('div', { @@ -2235,7 +2395,7 @@ return row; } - // --- Confirmed row (green success, auto-dismisses) --- + // Confirmed row (green success, auto-dismisses) function buildConfirmedRow() { const row = el('div', { @@ -2256,7 +2416,7 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders function buildDots(clickable) { const container = el('div', { @@ -2290,10 +2450,7 @@ const idx = i; dot.addEventListener('click', (e) => { e.stopPropagation(); - visibleVariant = idx; - showVariantInDOM(currentSessionId, idx); - updateSelectedElement(); - updateBarContent('cycling'); + selectVariant(idx, 'variant_changed'); }); } container.appendChild(dot); @@ -2323,13 +2480,14 @@ function el(tag, styles) { const e = document.createElement(tag); + if (String(tag).toLowerCase() === 'button') e.type = 'button'; if (styles) Object.assign(e.style, styles); return e; } - // --------------------------------------------------------------------------- + // // Action picker popover - // --------------------------------------------------------------------------- + // function initActionPicker() { const P = barPaletteForTheme(detectPageTheme()); @@ -2384,16 +2542,20 @@ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent'; }); chip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + const prompt = uiGetById(PREFIX + '-input')?.value || ''; selectedAction = action.value; hideActionPicker(); updateBarContent('configure'); + const input = uiGetById(PREFIX + '-input'); + if (input && prompt) input.value = prompt; }); grid.appendChild(chip); }); pickerEl.appendChild(grid); - document.body.appendChild(pickerEl); + uiAppend(pickerEl); defangOutsideHandlers(pickerEl); // Cache the palette on the picker so toggleActionPicker's state refresh @@ -2433,7 +2595,33 @@ setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180); } - // --------------------------------------------------------------------------- + function ensureCyclingRenderable(reason) { + if (arrivedVariants > 0) { + if (visibleVariant < 1 || visibleVariant > arrivedVariants) visibleVariant = 1; + return true; + } + recoverEmptyCycling(reason); + return false; + } + + function recoverEmptyCycling(reason) { + if (recoveringEmptyCycling) return; + recoveringEmptyCycling = true; + try { + console.warn('[impeccable] Refusing to render empty variant cycling state:', reason); + const message = 'No variants were mounted. Please try again.'; + if (svelteComponentSession?.sessionId === currentSessionId) { + abortSvelteComponentInjection(currentSessionId, message); + return; + } + cleanup(); + showToast(message, 5000); + } finally { + recoveringEmptyCycling = false; + } + } + + // // Params panel (per-variant coarse controls) // // Variants may declare a parameter manifest via a JSON attribute on the @@ -2446,13 +2634,13 @@ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped // CSS can respond instantly without regeneration: // - // range / numeric toggle → CSS var (`--p-`) used via var(--p-foo, N) + // range / numeric toggle -> CSS custom property used by variant styles // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."] // // On variant switch, values reset to that variant's declared defaults. // On accept, current values are sent in the event payload so the agent // can bake them into the source-file write. - // --------------------------------------------------------------------------- + // let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide) let paramsPanelInner = null; // translating content (carries bg, padding, knobs) @@ -2507,7 +2695,7 @@ }); paramsPanelEl.appendChild(paramsPanelBody); - document.body.appendChild(paramsPanelEl); + uiAppend(paramsPanelEl); // Don't override pointer-events: the panel toggles between 'none' (closed, // click-through) and 'auto' (open) on its own. Just silence the host's // outside-interaction listeners while the panel is open. @@ -2516,14 +2704,40 @@ } + function getMountedSvelteComponentAnchor(session = svelteComponentSession) { + const el = session?.mountTargetEl?.firstElementChild || null; + if (!el || !document.body.contains(el)) return null; + return rectIsUsableAnchor(el.getBoundingClientRect()) ? el : null; + } + + function resolveSvelteComponentAnchor(session = svelteComponentSession) { + return getMountedSvelteComponentAnchor(session) + || session?.swapAnchor + || null; + } + function getVisibleVariantEl() { if (!currentSessionId) return null; + if (svelteComponentSession?.sessionId === currentSessionId) { + return resolveSvelteComponentAnchor() + || svelteComponentSession.wrapperEl + || null; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } function parseVariantParams(variantEl) { + // Svelte component variants can't carry a `data-impeccable-params` attribute: + // the compiler reads `{` inside attribute values as expression delimiters, so + // JSON-with-braces breaks the build. For that path the params live in a sidecar + // params.json keyed by variant number, loaded into the session at mount time. + if (svelteComponentSession?.sessionId === currentSessionId) { + const byVariant = svelteComponentSession.paramsByVariant || {}; + const params = byVariant[String(visibleVariant)] || byVariant[visibleVariant]; + return Array.isArray(params) ? params : []; + } if (!variantEl) return []; const raw = variantEl.getAttribute('data-impeccable-params'); if (!raw) return []; @@ -2685,11 +2899,11 @@ } } - // --------------------------------------------------------------------------- + // // Inline text editing - makes pure-text descendants of the picked element // directly contenteditable. Save stages copy edits in the live buffer; the // Apply copy edits dock later asks the AI to apply the staged batch. - // --------------------------------------------------------------------------- + // let inlineEditRows = []; let inlineEditDrafts = new Map(); @@ -2803,7 +3017,7 @@ function disableInlineEdit(opts = {}) { for (const row of inlineEditRows) { - if (document.activeElement === row.el) row.el.blur(); + if (activeElementDeep() === row.el) row.el.blur(); row.el.removeAttribute('contenteditable'); delete row.el.dataset.impeccableEditable; delete row.el.dataset.impeccableOriginalText; @@ -3133,7 +3347,7 @@ if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) { showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); } else { - showToast('Save failed: retry or cancel', 4000); + showToast('Save failed - retry or cancel', 4000); } } } @@ -3181,11 +3395,11 @@ } function ensureSpinKeyframes() { - if (document.getElementById(PREFIX + '-keyframes')) return; + if (uiGetById(PREFIX + '-keyframes')) return; const style = document.createElement('style'); style.id = PREFIX + '-keyframes'; style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }'; - document.head.appendChild(style); + uiAppendStyle(style); } function pendingApplyLabel(count) { @@ -3318,10 +3532,10 @@ closeTunePopover(); } if (barEl && barEl.style.display !== 'none' && state === 'CONFIGURING') { - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value : ''; updateBarContent('configure'); - const nextInput = document.getElementById(PREFIX + '-input'); + const nextInput = uiGetById(PREFIX + '-input'); if (nextInput) nextInput.value = prompt; } if (editBadgeEl && editBadgeEl.style.display !== 'none') { @@ -3455,19 +3669,19 @@ updatePendingCounter(remaining); if (result.failed && result.failed.length > 0) { console.warn('[impeccable] some copy edits failed:', result.failed); - showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed - see console', 5000); } else { const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0); if (n > 0) { showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); } else { console.warn('[impeccable] apply returned no verified edits:', result); - showToast('No edits applied, see console', 4000); + showToast('No edits applied - see console', 4000); } } } catch (err) { console.error('[impeccable] commit failed:', err); - showToast('Apply failed, see console', 4000); + showToast('Apply failed - see console', 4000); } finally { if (waitForSseCompletion) return; const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0; @@ -3497,7 +3711,7 @@ } } catch (err) { console.error('[impeccable] discard failed:', err); - showToast('Discard failed, see console', 4000); + showToast('Discard failed - see console', 4000); } } @@ -3645,7 +3859,7 @@ const failedCount = numberOrNull(msg.failedCount) || 0; const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0; if (failedCount > 0) { - showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000); + showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed - see console', 5000); } else if (appliedCount > 0) { showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500); } @@ -3799,9 +4013,164 @@ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); } - // --------------------------------------------------------------------------- + // // Edit content badge - floating button at element top-right to enter EDITING mode - // --------------------------------------------------------------------------- + // + + function usesShadowChromeRoot() { + const root = liveUiRoot(); + return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; + } + + function setImportantStyle(el, name, value) { + el.style.setProperty(name, value, 'important'); + } + + function initEditBadgeHitProxies() { + if (!usesShadowChromeRoot() || editBadgeProxyRoot) return; + editBadgeProxyRoot = document.createElement('div'); + editBadgeProxyRoot.id = PREFIX + '-edit-badge-hit-proxies'; + editBadgeProxyRoot.setAttribute('aria-hidden', 'true'); + const styles = { + all: 'initial', + position: 'fixed', + inset: '0', + width: '100vw', + height: '100vh', + zIndex: String(Z.toast + 1), + pointerEvents: 'none', + background: 'transparent', + overflow: 'visible', + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + document.body.appendChild(editBadgeProxyRoot); + } + + function styleEditBadgeProxy(proxy, target) { + const rect = target.getBoundingClientRect(); + const cursor = getComputedStyle(target).cursor || 'pointer'; + const styles = { + all: 'initial', + position: 'fixed', + left: rect.left + 'px', + top: rect.top + 'px', + width: rect.width + 'px', + height: rect.height + 'px', + margin: '0', + padding: '0', + border: '0', + borderRadius: '0', + background: 'transparent', + color: 'transparent', + opacity: '0.001', + pointerEvents: 'auto', + cursor, + zIndex: String(Z.toast + 2), + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(proxy, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + } + + function proxyMouseEvent(type, source, target) { + let event; + try { + event = new MouseEvent(type, { + bubbles: type !== 'mouseenter' && type !== 'mouseleave', + cancelable: true, + composed: true, + clientX: source.clientX, + clientY: source.clientY, + screenX: source.screenX, + screenY: source.screenY, + button: source.button || 0, + buttons: source.buttons || 0, + ctrlKey: source.ctrlKey, + metaKey: source.metaKey, + shiftKey: source.shiftKey, + altKey: source.altKey, + }); + target.dispatchEvent(event); + } catch {} + } + + function bindEditBadgeProxy(proxy, target) { + const stop = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + proxy.addEventListener('mouseenter', (event) => { + stop(event); + proxyMouseEvent('mouseenter', event, target); + proxyMouseEvent('mouseover', event, target); + }); + proxy.addEventListener('mouseleave', (event) => { + stop(event); + proxyMouseEvent('mouseleave', event, target); + proxyMouseEvent('mouseout', event, target); + }); + proxy.addEventListener('mousedown', (event) => { + stop(event); + target.focus?.({ preventScroll: true }); + proxyMouseEvent('mousedown', event, target); + }); + proxy.addEventListener('mouseup', (event) => { + stop(event); + proxyMouseEvent('mouseup', event, target); + }); + proxy.addEventListener('click', (event) => { + stop(event); + target.click(); + syncEditBadgeHitProxies(); + }); + } + + function editBadgeProxyTargets() { + if (!usesShadowChromeRoot() || !editBadgeEl || editBadgeEl.style.display === 'none') return []; + return [...editBadgeEl.querySelectorAll('button')].filter((target) => { + if (target.disabled) return false; + const rect = target.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return false; + const style = getComputedStyle(target); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); + } + + function syncEditBadgeHitProxies() { + if (!usesShadowChromeRoot()) { + if (editBadgeProxyRoot) editBadgeProxyRoot.remove(); + editBadgeProxyRoot = null; + editBadgeProxyByTarget = new Map(); + return; + } + initEditBadgeHitProxies(); + if (!editBadgeProxyRoot) return; + const targets = editBadgeProxyTargets(); + const active = new Set(targets); + for (const [target, proxy] of editBadgeProxyByTarget) { + if (!active.has(target) || !target.isConnected) { + proxy.remove(); + editBadgeProxyByTarget.delete(target); + } + } + for (const target of targets) { + let proxy = editBadgeProxyByTarget.get(target); + if (!proxy) { + proxy = document.createElement('button'); + proxy.type = 'button'; + proxy.tabIndex = -1; + proxy.dataset.impeccableEditBadgeProxy = 'true'; + proxy.setAttribute('aria-hidden', 'true'); + bindEditBadgeProxy(proxy, target); + editBadgeProxyRoot.appendChild(proxy); + editBadgeProxyByTarget.set(target, proxy); + } + proxy.title = target.title || target.textContent || 'Edit copy'; + styleEditBadgeProxy(proxy, target); + } + } function initEditBadge() { editBadgeEl = document.createElement('div'); @@ -3813,10 +4182,11 @@ display: 'none', userSelect: 'none', }); - document.body.appendChild(editBadgeEl); + uiAppend(editBadgeEl); + initEditBadgeHitProxies(); // Remove focus rings on edit badge buttons + contenteditable elements - if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + if (!uiGetById(PREFIX + '-edit-badge-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-edit-badge-focus-style'; s.textContent = @@ -3826,21 +4196,26 @@ '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; - document.head.appendChild(s); + uiAppendStyle(s); } } function positionEditBadge() { - if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') { + syncEditBadgeHitProxies(); + return; + } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { if (editBadgeEl) editBadgeEl.style.display = 'none'; + syncEditBadgeHitProxies(); return; } editBadgeEl.style.display = 'flex'; @@ -4047,7 +4422,7 @@ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN; } // Re-render the bar so the Tune chip picks up the active styling. - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } function closeTunePopover() { @@ -4055,13 +4430,13 @@ hideParamsPanel(); if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT; if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') { - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } } - // --------------------------------------------------------------------------- + // // Variant cycling in DOM - // --------------------------------------------------------------------------- + // function isVariantShown(el) { if (!el) return false; @@ -4081,9 +4456,42 @@ } } - function showVariantInDOM(sessionId, num) { + function scheduleCyclingBarSync(sessionId, variantNum) { + requestAnimationFrame(() => { + if (state !== 'CYCLING') return; + if (currentSessionId !== sessionId) return; + if (visibleVariant !== variantNum) return; + showOrUpdateCyclingBar(); + syncCyclingControls(); + positionBar(); + }); + } + + function syncCyclingControls() { + const shown = svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0 + ? svelteComponentSession.mountedVariant + : visibleVariant; + const counter = uiGetById(PREFIX + '-variant-counter'); + if (counter && arrivedVariants > 0) counter.textContent = shown + '/' + arrivedVariants; + const prev = uiGetById(PREFIX + '-variant-prev'); + const next = uiGetById(PREFIX + '-variant-next'); + if (prev) prev.style.opacity = shown <= 1 ? '0.3' : '1'; + if (next) next.style.opacity = shown >= arrivedVariants ? '0.3' : '1'; + if (currentSessionId && state === 'CYCLING') saveSession(); + } + + async function showVariantInDOM(sessionId, num) { + if (svelteComponentSession?.sessionId === sessionId) { + visibleVariant = num; + const mounted = await mountSvelteComponentVariant(num); + if (!mounted) return false; + updateSelectedElement(); + refreshParamsPanel(); + scheduleCyclingBarSync(sessionId, num); + return true; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); - if (!wrapper) return; + if (!wrapper) return false; for (const child of wrapper.children) { const v = child.dataset ? child.dataset.impeccableVariant : null; if (!v) continue; @@ -4093,6 +4501,378 @@ // CYCLING yet, the subsequent CYCLING transition triggers its own // refresh) and every cycle step. refreshParamsPanel(); + return true; + } + + function isSvelteComponentManifestPath(filePath) { + return String(filePath || '').endsWith('manifest.json'); + } + + function parseOriginalMarkupElement(originalMarkup) { + const parser = new DOMParser(); + const doc = parser.parseFromString('
' + originalMarkup + '
', 'text/html'); + return doc.getElementById('impeccable-anchor')?.firstElementChild || null; + } + + function findLiveElementForOriginalMarkup(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const tag = origContent.tagName.toLowerCase(); + const cls = origContent.className; + let liveEl = null; + if (origContent.id) { + liveEl = document.getElementById(origContent.id); + } else if (cls) { + const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + for (const c of candidates) { + if (c.className === cls && !own(c)) { liveEl = c; break; } + } + if (!liveEl) { + const expectedClasses = String(cls).split(/\s+/).filter(Boolean); + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } + } + } + return liveEl; + } + + function isSvelteInsertManifest(manifest) { + return manifest?.previewMode === 'svelte-component' && manifest?.mode === 'insert'; + } + + function findLiveElementForSvelteManifest(manifest) { + if (isSvelteInsertManifest(manifest)) { + const anchor = findInsertAnchorInDom(); + if (anchor?.parentElement) return anchor; + } + return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function loadSvelteRuntime(runtimeModule) { + const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js'; + const url = new URL(modulePath, location.origin).href; + if (!svelteRuntimePromise) { + svelteRuntimePromise = import(/* @vite-ignore */ url); + } + return svelteRuntimePromise; + } + + // Svelte component variants declare their params in a sidecar params.json under + // componentDir (keyed by variant number), because a `data-impeccable-params` + // attribute with JSON braces can't survive the Svelte compiler. Returns a map of + // { "1": [...params], "2": [...] }; an empty object when the agent declared none. + async function loadSvelteComponentParams(manifest) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir) return {}; + const paramsPath = dir + '/params.json'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(paramsPath); + try { + const res = await fetch(url); + if (!res.ok) return {}; + const parsed = JSON.parse(await res.text()); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out = {}; + for (const [key, value] of Object.entries(parsed)) { + if (Array.isArray(value)) out[String(key)] = value; + } + return out; + } catch { + return {}; + } + } + + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { + const contract = manifest?.propContract || []; + const values = {}; + if (!liveEl || contract.length === 0) return values; + const sourceOriginal = parseOriginalMarkupElement(manifest.originalMarkup || ''); + if (!sourceOriginal) return values; + const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl); + for (const entry of contract) { + const token = '{' + entry.expr + '}'; + values[entry.prop] = map.get(token) || ''; + } + return values; + } + + async function mountSvelteComponentVariant(variantNum) { + if (!svelteComponentSession || !variantNum) return false; + const { manifest, mountTargetEl, sessionId } = svelteComponentSession; + try { + const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement; + svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null; + const runtime = await loadSvelteRuntime(manifest.runtimeModule); + const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte'; + const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now(); + const mod = await import(/* @vite-ignore */ moduleUrl); + const Component = mod.default; + if (svelteComponentSession.mountedInstance && runtime.unmount) { + await runtime.unmount(svelteComponentSession.mountedInstance); + svelteComponentSession.mountedInstance = null; + } + svelteComponentSession.mountedInstance = runtime.mount(Component, { + target: mountTargetEl, + props: { ...svelteComponentSession.propValues }, + intro: false, + }); + svelteComponentSession.mountedVariant = variantNum; + svelteComponentSession.runtime = runtime; + if (state === 'CYCLING') syncCyclingControls(); + const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (nextAnchor) { + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(nextAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = nextAnchor; + } else { + requestAnimationFrame(() => { + if (svelteComponentSession?.sessionId !== sessionId) return; + const settledAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!settledAnchor) return; + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(settledAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = settledAnchor; + }); + } + return true; + } catch (err) { + if (svelteComponentSession?.sessionId === sessionId) { + svelteComponentSession.swapAnchor = null; + } + console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err); + return false; + } + } + + function teardownSvelteComponentSession(restoreOriginal) { + if (!svelteComponentSession) return; + const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + if (restoreOriginal && detachedOriginal && wrapperEl?.parentElement) { + wrapperEl.parentElement.replaceChild(detachedOriginal, wrapperEl); + } else if (wrapperEl?.parentElement) { + wrapperEl.remove(); + } + svelteComponentSession = null; + svelteRuntimePromise = null; + } + + function applyOriginalAttrsToSvelteAnchor(el, originalMarkup) { + if (!el || !originalMarkup) return; + const original = parseOriginalMarkupElement(originalMarkup); + if (!original || original.tagName !== el.tagName) return; + for (const attr of original.attributes) { + if (attr.name === 'class') { + for (const className of attr.value.split(/\s+/).filter(Boolean)) { + el.classList.add(className); + } + } else if (!el.hasAttribute(attr.name)) { + el.setAttribute(attr.name, attr.value); + } + } + } + + function commitAcceptedSvelteComponentToDom(sessionId) { + if (!svelteComponentSession || svelteComponentSession.sessionId !== sessionId) return false; + const { wrapperEl, runtime, mountedInstance, manifest } = svelteComponentSession; + const anchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!anchor || !wrapperEl?.parentElement) return false; + const committed = anchor.cloneNode(true); + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(committed, manifest.originalMarkup || ''); + } + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + wrapperEl.parentElement.replaceChild(committed, wrapperEl); + svelteComponentSession = null; + svelteRuntimePromise = null; + selectedElement = committed; + return true; + } + + async function injectSvelteComponentsFromManifest(manifestPath, sessionId) { + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(manifestPath); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + const manifest = JSON.parse(await res.text()); + if (manifest.id !== sessionId) return; + + const paramsByVariant = await loadSvelteComponentParams(manifest); + currentSessionId = sessionId; + expectedVariants = Number(manifest.count) || expectedVariants || 1; + rememberSessionFileMeta({ + sourceFile: manifest.sourceFile, + previewFile: manifestPath, + previewMode: 'svelte-component', + }); + if (state !== 'CYCLING') state = 'GENERATING'; + + const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { + recoveryWaitingForAnchor = false; + svelteComponentSession.paramsByVariant = paramsByVariant; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; + await mountSvelteComponentVariant(visibleVariant || 1); + state = 'CYCLING'; + showOrUpdateCyclingBar(); + saveSession(); + return; + } + + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) { + console.warn('[impeccable] Could not find original element in live DOM.'); + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants + ? visibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + saveSession(); + queueCheckpoint('svelte_component_anchor_missing'); + waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return; + } + + const wrapper = document.createElement('div'); + wrapper.dataset.impeccableVariants = sessionId; + wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1); + wrapper.dataset.impeccablePreview = 'svelte-component'; + wrapper.style.display = 'contents'; + + const mountTarget = document.createElement('div'); + mountTarget.dataset.impeccableComponentMount = sessionId; + mountTarget.style.display = 'contents'; + wrapper.appendChild(mountTarget); + + const insertMode = isSvelteInsertManifest(manifest); + const detachedOriginal = insertMode ? null : liveEl; + if (insertMode) { + removeInsertPlaceholderDom(); + if (manifest.position === 'before') liveEl.parentElement.insertBefore(wrapper, liveEl); + else liveEl.parentElement.insertBefore(wrapper, liveEl.nextSibling); + } else { + liveEl.parentElement.replaceChild(wrapper, liveEl); + } + + svelteComponentSession = { + sessionId, + manifest, + insertMode, + wrapperEl: wrapper, + mountTargetEl: mountTarget, + detachedOriginal, + mountedInstance: null, + mountedVariant: 0, + runtime: null, + propValues: buildSveltePropValuesFromLiveElement(detachedOriginal, manifest), + paramsByVariant, + }; + if (pendingSvelteComponentRetryObserver) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + } + recoveryWaitingForAnchor = false; + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + + const mounted = await mountSvelteComponentVariant(visibleVariant); + if (!mounted) { + // The compiled component threw (e.g. a Svelte compile error in the + // variant file). Don't strand the bar in an empty CYCLING state; restore + // the original element and reset to PICKING so the user can retry. + abortSvelteComponentInjection(sessionId, 'A variant failed to compile. Fix the component and re-run.'); + return; + } + + selectedElement = mountTarget.firstElementChild || mountTarget; + state = 'CYCLING'; + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.'); + } catch (err) { + console.error('[impeccable] Failed to mount Svelte component variants:', err); + abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.'); + } + } + + function waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }) { + if (pendingSvelteComponentRetryObserver) pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = new MutationObserver(() => { + if (svelteComponentSession?.sessionId === sessionId) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + return; + } + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) return; + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + injectSvelteComponentsFromManifest(manifestPath, sessionId); + }); + pendingSvelteComponentRetryObserver.observe(document.body, { childList: true, subtree: true }); + } + + // Reset cleanly when a Svelte component session can't mount: tear the wrapper + // down (restoring the original element), clear persisted session state, and + // return the bar to PICKING. Avoids the stuck 0/0 CYCLING bar. + function abortSvelteComponentInjection(sessionId, message) { + try { + if (svelteComponentSession?.sessionId === sessionId) { + teardownSvelteComponentSession(true); + } else { + const orphan = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (orphan) orphan.remove(); + } + } catch (err) { + console.warn('[impeccable] Svelte component abort cleanup failed:', err); + } + hideShaderOverlay(); + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + stopScrollLock(); + clearSession(); + clearHandled(); + resetSessionFileMeta(); + currentSessionId = null; + expectedVariants = 0; + arrivedVariants = 0; + visibleVariant = 0; + selectedElement = null; + state = 'PICKING'; + hideBar(); + if (message) showToast(message, 5000); } /** @@ -4101,6 +4881,11 @@ * This works even when the dev server caches HTML (Bun, static servers). */ function injectVariantsFromSource(filePath, sessionId) { + if (isSvelteComponentManifestPath(filePath)) { + injectSvelteComponentsFromManifest(filePath, sessionId); + return; + } + rememberSessionFileMeta({ file: filePath }); const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) @@ -4119,7 +4904,7 @@ const doc = parser.parseFromString(block, 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { - console.error('[impeccable] Variant wrapper not found in source file.'); + console.warn('[impeccable] Variant wrapper not found in source file.'); return; } @@ -4134,31 +4919,31 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; - if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - } - + const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); if (!liveEl) { - console.error('[impeccable] Could not find original element in live DOM.'); + console.warn('[impeccable] Could not find original element in live DOM.'); + selectedElement = document.body; + recoveryWaitingForAnchor = true; + state = 'GENERATING'; + showBar('generating'); + saveSession(); + showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } + recoveryWaitingForAnchor = false; // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); arrivedVariants = variants.length; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + recoverEmptyCycling('source-fallback-empty'); + return; + } const saved = loadSession(); const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants @@ -4170,8 +4955,9 @@ selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4184,21 +4970,129 @@ }); } - function cycleVariant(dir) { + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { + const map = new Map(); + if (!sourceOriginal || !liveOriginal) return map; + + const sourceNodes = collectTextNodes(sourceOriginal) + .filter((node) => /\{[^{}]+\}/.test(node.nodeValue || '')); + const liveTexts = collectTextNodes(liveOriginal) + .map((node) => normalizePreviewText(node.nodeValue || '')) + .filter(Boolean); + let liveIndex = 0; + + for (const sourceNode of sourceNodes) { + const sourceText = sourceNode.nodeValue || ''; + const tokens = sourceText.match(/\{[^{}]+\}/g) || []; + if (tokens.length === 0) continue; + + const liveText = liveTexts[liveIndex++] || ''; + if (!liveText) continue; + + if (tokens.length === 1) { + const token = tokens[0]; + const normalizedSource = normalizePreviewText(sourceText); + if (normalizedSource === token) { + map.set(token, liveText); + continue; + } + + const match = liveText.match(expressionTextMatcher(sourceText, [token])); + if (match && match[1]) map.set(token, match[1].trim()); + continue; + } + + if (normalizePreviewText(sourceText) === tokens.join(' ')) { + for (const token of tokens) { + const tokenLiveText = liveTexts[liveIndex - 1] || ''; + if (tokenLiveText) map.set(token, tokenLiveText); + } + } + } + + return map; + } + + function expressionTextMatcher(sourceText, tokens) { + let pattern = '^'; + let cursor = 0; + for (const token of tokens) { + const index = sourceText.indexOf(token, cursor); + if (index === -1) continue; + pattern += escapeRegExp(sourceText.slice(cursor, index)).replace(/\s+/g, '\\s*'); + pattern += '(.*?)'; + cursor = index + token.length; + } + pattern += escapeRegExp(sourceText.slice(cursor)).replace(/\s+/g, '\\s*') + '$'; + return new RegExp(pattern); + } + + function collectTextNodes(root) { + if (!root) return []; + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + nodes.push(node); + node = walker.nextNode(); + } + return nodes; + } + + function normalizePreviewText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + async function selectVariant(next, checkpointReason) { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - const next = visibleVariant + dir; + if (variantSelectionInFlight) return; if (next < 1 || next > arrivedVariants) return; - visibleVariant = next; - showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself - updateSelectedElement(); - updateBarContent('cycling'); - positionBar(); - saveSession(); - queueCheckpoint('variant_changed'); + if (next === visibleVariant) return; + + const previous = visibleVariant; + variantSelectionInFlight = true; + const selectionPromise = (async () => { + visibleVariant = next; + showOrUpdateCyclingBar(); + saveSession(); + const shown = await showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself + if (!shown) { + visibleVariant = previous; + await showVariantInDOM(currentSessionId, previous); + showOrUpdateCyclingBar(); + saveSession(); + return; + } + updateSelectedElement(); + showOrUpdateCyclingBar(); + positionBar(); + saveSession(); + if (checkpointReason) queueCheckpoint(checkpointReason); + })(); + variantSelectionPromise = selectionPromise; + try { + await selectionPromise; + } finally { + if (variantSelectionPromise === selectionPromise) variantSelectionPromise = null; + variantSelectionInFlight = false; + } + } + + function cycleVariant(dir) { + selectVariant(visibleVariant + dir, 'variant_changed'); } function updateSelectedElement() { if (!currentSessionId) return; + if (svelteComponentSession?.sessionId === currentSessionId) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; + return; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); @@ -4206,6 +5100,9 @@ } function readVisibleVariantFromDOM(sessionId) { + if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { + return svelteComponentSession.mountedVariant; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -4341,9 +5238,9 @@ // scrollY that the next resume needs to read. } - // --------------------------------------------------------------------------- + // // MutationObserver for progressive variant reveal - // --------------------------------------------------------------------------- + // function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -4426,10 +5323,11 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); updateSelectedElement(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4445,9 +5343,9 @@ return obs; } - // --------------------------------------------------------------------------- + // // Bar scroll tracking - // --------------------------------------------------------------------------- + // function startScrollTracking() { function tick() { @@ -4483,10 +5381,10 @@ if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; } } - // --------------------------------------------------------------------------- + // // SSE (server→browser) + fetch POST (browser→server) // Zero-dependency replacement for WebSocket. - // --------------------------------------------------------------------------- + // let evtSource = null; let sseRetries = 0; @@ -4509,6 +5407,7 @@ console.log('[impeccable] Live mode connected.'); syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); + restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; syncPageChatFocus('sse-connected'); break; @@ -4531,11 +5430,12 @@ break; case 'done': if (maybeCompleteSteer(msg)) break; + rememberSessionFileMeta(msg); // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); } @@ -4557,7 +5457,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; if (state !== 'GENERATING') return; showToast( - "Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.", + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", 15000, ); }, 2000); @@ -4571,12 +5471,18 @@ // the final complete event. Keep the browser in its recoverable // saving state while the source cleanup is still in flight. break; + case 'discarded': + if (msg.id && msg.id === currentSessionId) { + markSessionHandled(); + cleanup(); + } + break; case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; state = 'CYCLING'; updateBarContent('cycling'); - showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000); + showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; } if (maybeCompleteSteer(msg)) break; @@ -4656,6 +5562,9 @@ expectedVariants, arrivedVariants, visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, paramValues: { ...paramsCurrentValues }, }; } @@ -4665,6 +5574,20 @@ return sendEvent(checkpointPayload(reason)).catch(() => null); } + function sendSteerCheckpoint(id, reason, extra) { + if (!id) return Promise.resolve(null); + return sendEvent({ + type: 'checkpoint', + id, + revision: sessionState.nextCheckpointRevision(), + owner: browserOwner, + phase: 'steer', + reason, + pageUrl: location.pathname, + ...(extra || {}), + }).catch(() => null); + } + function queueCheckpoint(reason) { if (!currentSessionId) return; if (checkpointTimer) clearTimeout(checkpointTimer); @@ -4674,9 +5597,9 @@ }, 120); } - // --------------------------------------------------------------------------- + // // Event handlers - // --------------------------------------------------------------------------- + // function handleMouseMove(e) { if (pendingApplyInFlight) return; @@ -4860,7 +5783,7 @@ // // DISABLED: quick-Go workflows pay an extra harness round trip because // prefetch + generate arrive as two events instead of one. Re-enable with - // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to + // a browser-side debounce (~800-1000ms, cancelled on Go) if we want to // resurrect this. Server validator and skill dispatch remain in place so // flipping this flag is the only change needed. const PREFETCH_ENABLED = false; @@ -4876,6 +5799,14 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + const deepActive = activeElementDeep(); + if ( + deepActive + && own(deepActive) + && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + ) { + return; + } // While a contenteditable text-leaf is focused, let the browser handle // all keys except Escape. Escape cancels the current edit (restores // original text) and blurs without saving, staying in CONFIGURING. @@ -4982,7 +5913,7 @@ if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!selectedElement || state !== 'CONFIGURING') return; stopVoice({ suppressSubmit: true }); - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; // Commit any pending pin edit BEFORE we snapshot annotations. @@ -4996,6 +5927,7 @@ expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); // Flip to GENERATING immediately so the bar morphs without waiting on // capture + upload. The event is emitted from captureAndEmit() once the @@ -5054,7 +5986,7 @@ function handleInsertCreate() { if (!placeholderElement || !insertAnchorElement || state !== 'CONFIGURING' || configureKind !== 'insert') return; - const input = document.getElementById(PREFIX + '-insert-input'); + const input = uiGetById(PREFIX + '-insert-input'); const prompt = input ? input.value.trim() : ''; if (annotEditing) finalizeEditingPin(); const snapshot = { @@ -5064,10 +5996,12 @@ if (!canCreateInsert({ prompt, comments: snapshot.comments, strokes: snapshot.strokes })) return; stopVoice({ suppressSubmit: true }); + pendingAcceptedSession = null; currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); selectedElement = placeholderElement; insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement); @@ -5107,9 +6041,9 @@ captureAndEmit(elForCapture, basePayload, snapshot, captureRect); } - // --------------------------------------------------------------------------- + // // Screenshot capture + upload - // --------------------------------------------------------------------------- + // let msLoadPromise = null; function loadModernScreenshot() { @@ -5120,7 +6054,7 @@ s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; s.onload = () => resolve(window.modernScreenshot); s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; - document.head.appendChild(s); + uiAppendStyle(s); }); return msLoadPromise; } @@ -5235,11 +6169,113 @@ return '#ffffff'; } + function captureChromeNodes() { + const nodes = []; + const add = (node) => { + if (!node || node === document.body || nodes.includes(node)) return; + nodes.push(node); + }; + add(document.getElementById(PREFIX + '-root')); + [ + PREFIX + '-highlight', + PREFIX + '-tooltip', + PREFIX + '-bar', + PREFIX + '-picker', + PREFIX + '-params-panel', + PREFIX + '-insert-line', + PREFIX + '-insert-placeholder', + PREFIX + '-insert-create-tooltip', + PREFIX + '-annot', + PREFIX + '-design-host', + PREFIX + '-toast', + PREFIX + '-shader', + ].forEach((id) => add(uiGetById(id))); + return nodes; + } + + async function hideCaptureChromeForShaderProxy(fn) { + const saved = captureChromeNodes().map((node) => ({ + node, + visibility: node.style.visibility, + priority: node.style.getPropertyPriority('visibility'), + })); + for (const { node } of saved) { + node.style.setProperty('visibility', 'hidden', 'important'); + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + try { + return await fn(); + } finally { + for (const { node, visibility, priority } of saved) { + node.style.setProperty('visibility', visibility, priority); + } + } + } + + function shouldUseAncestorCropShaderProxy(el) { + // TODO: Enable this proxy for React/Vue/etc. adapters once their live + // preview mounts are covered by the same shader regression checks. + const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase(); + if (adapter === 'svelte' || adapter === 'sveltekit') return true; + if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true; + const wrapper = el?.closest?.('[data-impeccable-variants]'); + return wrapper?.dataset?.impeccablePreview === 'svelte-component'; + } + + function paintsShaderProxySurface(node) { + const s = getComputedStyle(node); + return !isTransparentColor(s.backgroundColor) + || (s.backgroundImage && s.backgroundImage !== 'none') + || paintsBackdrop(node); + } + + function findShaderProxyCaptureRoot(el) { + const doc = el.ownerDocument || document; + const er = el.getBoundingClientRect(); + let node = el.parentElement; + while (node && node !== doc.documentElement) { + const nr = node.getBoundingClientRect(); + const containsElement = + nr.width > 0 && nr.height > 0 && + nr.left <= er.left + 0.5 && + nr.top <= er.top + 0.5 && + nr.right >= er.right - 0.5 && + nr.bottom >= er.bottom - 0.5; + if (containsElement && paintsShaderProxySurface(node)) return node; + node = node.parentElement; + } + return null; + } + // Capture the element (with current annotations baked in) and return // { blob, paper }: the PNG Blob, plus the representative backdrop tone for the // shader's halftone ground (so capture, upload, and shader all agree on what // sits behind the element). Shared between the Go flow (uploads the blob) and // the shader-resume path. + async function captureElementFromRenderedAncestor(ms, el, opts) { + const doc = el.ownerDocument || document; + const captureRoot = findShaderProxyCaptureRoot(el); + if (!captureRoot) throw new Error('No painted ancestor for Svelte shader proxy'); + const rootCanvas = await ms.domToCanvas(captureRoot, opts); + const S = opts.scale; + const er = el.getBoundingClientRect(); + const rr = captureRoot.getBoundingClientRect(); + const sx = (er.left - rr.left) * S; + const sy = (er.top - rr.top) * S; + const sw = er.width * S; + const sh = er.height * S; + if (sw <= 0 || sh <= 0) throw new Error('Selected element has no visible capture rect'); + const crop = doc.createElement('canvas'); + crop.width = Math.max(1, Math.round(sw)); + crop.height = Math.max(1, Math.round(sh)); + const cctx = crop.getContext('2d', { willReadFrequently: true }); + cctx.drawImage(rootCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height); + const paper = dominantRgb01(cctx, crop.width, crop.height) || averageRgb01(cctx, crop.width, crop.height); + const blob = await new Promise((res) => crop.toBlob(res, 'image/png')); + if (!blob) throw new Error('Ancestor crop failed to produce a PNG blob'); + return { blob, paper }; + } + async function captureElementToBlob(el, snapshot, rect) { try { if (document.fonts?.ready) await document.fonts.ready; } catch {} const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); @@ -5261,6 +6297,13 @@ scale: Math.min(window.devicePixelRatio || 1, 2), font: fontCssText ? { cssText: fontCssText } : undefined, }; + if (shouldUseAncestorCropShaderProxy(el)) { + try { + return await hideCaptureChromeForShaderProxy(() => captureElementFromRenderedAncestor(ms, el, opts)); + } catch (err) { + console.warn('[impeccable] Svelte ancestor crop capture failed, falling back to element capture:', err); + } + } const bg = resolveCanvasBackground(el); // Fast path: the element paints its own background, or an opaque ancestor // color was found. modern-screenshot bakes that color; paper matches it. @@ -5342,13 +6385,13 @@ sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); } - // --------------------------------------------------------------------------- + // // Shader overlay - renders the captured screenshot as a WebGL texture and // runs an editorial "ink-wash" fragment shader over it during generation. // A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku // and leaving a soft trail. Makes the wait feel like a letterpress scan // instead of a dead spinner. - // --------------------------------------------------------------------------- + // const SHADER_VS = `attribute vec2 a_position; attribute vec2 a_uv; @@ -5502,6 +6545,31 @@ void main() { return n ? [r / n / 255, g / n / 255, b / n / 255] : SHADER_PAPER_FALLBACK; } + // Pick the most common visible color cluster from a crop. A straight average + // gets pulled by text and icons; the dominant bucket usually represents the + // surface the shader should dissolve into. + function dominantRgb01(ctx, w, h) { + const data = ctx.getImageData(0, 0, w, h).data; + const stride = Math.max(1, Math.floor((w * h) / 6000)); + const buckets = new Map(); + for (let p = 0; p < w * h; p += stride) { + const i = p * 4; + if (data[i + 3] < 16) continue; + const key = (data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4); + const bucket = buckets.get(key) || { count: 0, r: 0, g: 0, b: 0 }; + bucket.count += 1; + bucket.r += data[i]; + bucket.g += data[i + 1]; + bucket.b += data[i + 2]; + buckets.set(key, bucket); + } + let best = null; + for (const bucket of buckets.values()) { + if (!best || bucket.count > best.count) best = bucket; + } + return best ? [best.r / best.count / 255, best.g / best.count / 255, best.b / best.count / 255] : null; + } + // Average the backdrop sampled just OUTSIDE an element's rect within a larger // canvas. The ground tone for the dissolve must be the real backdrop, not the // mean of the element's own crop - averaging the crop folds in the element's @@ -5572,7 +6640,7 @@ void main() { fallback.style.backgroundRepeat = 'no-repeat'; fallback.style.outline = '2px dashed ' + C.brand; fallback.style.outlineOffset = '-2px'; - document.body.appendChild(fallback); + uiAppend(fallback); shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl }; } @@ -5582,16 +6650,19 @@ void main() { const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); + const radius = getComputedStyle(el).borderRadius; canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); Object.assign(canvas.style, { position: 'fixed', top: rect.top + 'px', left: rect.left + 'px', width: rect.width + 'px', height: rect.height + 'px', + borderRadius: radius, + overflow: 'hidden', pointerEvents: 'none', zIndex: Z.bar - 1, }); - document.body.appendChild(canvas); + uiAppend(canvas); const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) || canvas.getContext('experimental-webgl'); @@ -5685,8 +6756,12 @@ void main() { frame(); } - function handleAccept() { + async function handleAccept() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } + if (pendingAcceptedSession || state === 'SAVING') return; + if (variantSelectionPromise) { + try { await variantSelectionPromise; } catch { /* failed selection falls back below */ } + } if (!currentSessionId || arrivedVariants === 0) return; const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId); if (domVisibleVariant > 0) visibleVariant = domVisibleVariant; @@ -5696,30 +6771,39 @@ void main() { variantId: String(visibleVariant), pageUrl: location.pathname, }; + const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } + // The accepted variant is already the only visible child of the wrapper + // (all other variants are display:none). HMR from the source rewrite will + // replace the wrapper imminently. Don't eagerly replaceChild here - React + // reconciliation races with our mutation and throws NotFoundError in Next + // 16 / Turbopack. Schedule a fallback that runs the manual swap only if + // HMR hasn't cleaned up by then (keeps static-server flows working). const acceptedSessionId = currentSessionId; const acceptedVariant = visibleVariant; + const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId + || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - pendingAcceptedSession = { - id: acceptedSessionId, - variant: String(acceptedVariant), - ...acceptedSnapshot, - finalizing: false, - }; state = 'SAVING'; updateBarContent('saving'); + pendingAcceptedSession = { + id: acceptedSessionId, + variant: String(acceptedVariant), + isSvelteComponent: acceptedIsSvelteComponent, + ...acceptedSnapshot, + finalizing: false, + }; + saveSession(); sendEvent(acceptPayload, { throwOnError: true }) - .then(() => { - markSessionHandled(); - }) + .then(() => {}) .catch(() => { - pendingAcceptedSession = null; + if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); } @@ -5733,19 +6817,21 @@ void main() { } if (pending.finalizing) return true; pending.finalizing = true; - + markSessionHandled(); + if (pending.isSvelteComponent) { + commitAcceptedSvelteComponentToDom(pending.id); + } state = 'CONFIRMED'; updateBarContent('confirmed'); + scheduleAcceptCleanup(pending); + return true; + } - // Give framework HMR a short chance to render the now-clean accepted - // source. If it misses the update, unwrap the accepted variant after the - // source-side completion event so the page is not left empty or stale. + function scheduleAcceptCleanup(accepted) { setTimeout(function() { - ensureAcceptedDomClean(pending); + if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted); cleanupAcceptedSession(); }, 1200); - - return true; } function snapshotAcceptedVariantDom(sessionId, variantId) { @@ -5833,6 +6919,7 @@ void main() { stopScrollLock(); clearScrollY(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5841,6 +6928,28 @@ void main() { state = 'PICKING'; } + function commitAcceptedVariantToDom(sessionId, variantId) { + const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapper) return false; + const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); + if (!accepted || !accepted.firstElementChild) return false; + const parent = wrapper.parentElement; + if (!parent) return false; + + const style = wrapper.querySelector('style[data-impeccable-css]'); + if (style && !document.querySelector('style[data-impeccable-accepted-css="' + sessionId + '"]')) { + const promotedStyle = style.cloneNode(true); + promotedStyle.setAttribute('data-impeccable-accepted-css', sessionId); + parent.insertBefore(promotedStyle, wrapper); + } + + const committed = accepted.cloneNode(true); + committed.removeAttribute('hidden'); + committed.style.display = 'contents'; + parent.replaceChild(committed, wrapper); + return true; + } + function handleDiscard() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!currentSessionId) return; @@ -5852,11 +6961,141 @@ void main() { .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000)); } - // --------------------------------------------------------------------------- + // // Session persistence via live-browser-session.js - // --------------------------------------------------------------------------- + // // Survives page reloads, browser close/reopen, HMR, and accidental refreshes. + function normalizeSessionPath(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed.replace(/\\/g, '/') : null; + } + + function resetSessionFileMeta() { + currentSourceFile = null; + currentPreviewFile = null; + currentPreviewMode = null; + recoveryWaitingForAnchor = false; + } + + function rememberSessionFileMeta(meta = {}) { + const file = normalizeSessionPath(meta.file); + const sourceFile = normalizeSessionPath(meta.sourceFile); + const previewFile = normalizeSessionPath(meta.previewFile); + const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null); + + if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) { + currentPreviewMode = 'svelte-component'; + currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile); + currentSourceFile = sourceFile || currentSourceFile; + return; + } + + if (sourceFile || file) currentSourceFile = sourceFile || file; + if (previewFile) currentPreviewFile = previewFile; + if (previewMode) currentPreviewMode = previewMode; + } + + function applySavedSessionMeta(saved) { + if (!saved) return; + rememberSessionFileMeta(saved); + if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.action) selectedAction = saved.action; + if (saved.count) selectedCount = saved.count; + if (saved.previewMode) currentPreviewMode = saved.previewMode; + if (saved.paramValues && typeof saved.paramValues === 'object') { + paramsCurrentValues = { ...saved.paramValues }; + } + } + + function normalizePagePath(value) { + if (!value || typeof value !== 'string') return null; + try { + return new URL(value, location.origin).pathname; + } catch { + return value.split(/[?#]/)[0] || null; + } + } + + function pageMatchesCurrent(value) { + const path = normalizePagePath(value); + return !path || path === location.pathname; + } + + function isTerminalSessionSummary(session) { + return /^(completed|discarded|discard_requested|accept_requested)$/.test(String(session?.phase || '')); + } + + function findActiveSessionSummary(saved, activeSessions) { + if (!saved?.id || !Array.isArray(activeSessions)) return null; + return activeSessions.find((session) => + session?.id === saved.id + && pageMatchesCurrent(session.pageUrl || saved.pageUrl) + && !isTerminalSessionSummary(session) + ) || null; + } + + function clampVariantIndex(value, count) { + const num = Number(value); + const max = Number(count); + if (!Number.isFinite(num) || num < 1) return 0; + if (Number.isFinite(max) && max > 0 && num > max) return 0; + return Math.floor(num); + } + + function restoreSessionWithoutWrapper(reason, activeSessions) { + const saved = loadSession(); + if (!saved?.id || isSessionHandled(saved.id)) return false; + const savedState = String(saved.state || '').toUpperCase(); + if (savedState !== 'GENERATING' && savedState !== 'CYCLING') return false; + + const serverSession = findActiveSessionSummary(saved, activeSessions); + if (Array.isArray(activeSessions) && activeSessions.length > 0 && !serverSession) { + return false; + } + + currentSessionId = saved.id; + applySavedSessionMeta(serverSession); + applySavedSessionMeta(saved); + + expectedVariants = Number(saved.expected || serverSession?.expectedVariants || selectedCount || 0); + arrivedVariants = Number(saved.arrived || serverSession?.arrivedVariants || 0); + if (arrivedVariants <= 0 && currentPreviewFile) arrivedVariants = Number(serverSession?.expectedVariants || saved.expected || selectedCount || 0); + if (expectedVariants <= 0) expectedVariants = Number(serverSession?.expectedVariants || arrivedVariants || selectedCount || 0); + visibleVariant = clampVariantIndex(saved.visible, arrivedVariants || expectedVariants) + || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) + || (arrivedVariants > 0 ? 1 : 0); + + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + if (variantObserver) variantObserver.disconnect(); + variantObserver = startVariantObserver(currentSessionId); + saveSession(); + queueCheckpoint(reason || 'browser_restore_without_wrapper'); + + const restoreFile = currentPreviewMode === 'svelte-component' + ? currentPreviewFile + : (currentSourceFile || currentPreviewFile); + if (restoreFile) { + injectVariantsFromSource(restoreFile, currentSessionId); + return true; + } + + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return true; + } + + function restoreFromActiveSessions(activeSessions, reason) { + const wrapper = document.querySelector('[data-impeccable-variants]'); + if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false; + if (svelteComponentSession?.sessionId === currentSessionId) return false; + return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); + } + function saveSession() { if (!currentSessionId) return; // NOTE: scrollY is stored under a separate key (writeScrollY). Storing @@ -5869,6 +7108,11 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, + pageUrl: location.pathname, + paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, }); } @@ -5898,31 +7142,33 @@ void main() { } function cleanup() { - // Hide the wrapper immediately so variants disappear. DON'T structurally - // mutate the DOM yet - HMR from the agent's source rewrite is on its way, - // and a manual replaceChild under React causes NotFoundError when the - // reconciler later tries to remove a wrapper we already removed. - // Schedule a 2s fallback that does the manual swap only if HMR hasn't - // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const cleanupSessionId = currentSessionId; - if (cleanupSessionId) { + if (svelteComponentSession?.sessionId === cleanupSessionId) { + teardownSvelteComponentSession(true); + } else if (cleanupSessionId) { + // Hide the wrapper immediately so variants disappear. DON'T structurally + // mutate the DOM yet - HMR from the agent's source rewrite is on its way, + // and a manual replaceChild under React causes NotFoundError when the + // reconciler later tries to remove a wrapper we already removed. + // Schedule a 2s fallback that does the manual swap only if HMR hasn't + // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); if (wrapper) wrapper.style.display = 'none'; - } - setTimeout(function() { - if (!cleanupSessionId) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!wrapper) return; - const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - wrapper.parentElement.replaceChild(content, wrapper); - return; + setTimeout(function() { + if (!cleanupSessionId) return; + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) return; + const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); + if (orig) { + const content = orig.firstElementChild; + if (content) { + lateWrapper.parentElement.replaceChild(content, lateWrapper); + return; + } } - } - wrapper.remove(); - }, 2000); + lateWrapper.remove(); + }, 2000); + } hideBar(); hideHighlight(); stopScrollTracking(); @@ -5931,6 +7177,7 @@ void main() { clearScrollY(); finalizeInsertSession(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5938,9 +7185,9 @@ void main() { state = 'PICKING'; } - // --------------------------------------------------------------------------- + // // Toast - // --------------------------------------------------------------------------- + // function showToast(message, duration) { if (toastEl) toastEl.remove(); @@ -5964,7 +7211,7 @@ void main() { }); toastEl.id = PREFIX + '-toast'; toastEl.textContent = message; - document.body.appendChild(toastEl); + uiAppend(toastEl); requestAnimationFrame(() => { toastEl.style.opacity = '1'; toastEl.style.transform = 'translateX(-50%) translateY(0)'; @@ -5978,22 +7225,70 @@ void main() { }, duration); } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. function resumeSession() { const wrapper = document.querySelector('[data-impeccable-variants]'); - if (!wrapper) { clearSession(); clearHandled(); return false; } + if (!wrapper) { + if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } const sessionId = wrapper.dataset.impeccableVariants; // Don't resume if this session was already accepted/discarded if (isSessionHandled(sessionId)) return false; + // Svelte component sessions can't be resumed by counting DOM children: the + // wrapper holds a single mount target, not [data-impeccable-variant] nodes, + // and a page reload unmounts every compiled variant. Counting children here + // would strand the bar in CYCLING at 0/0. If there's no live in-memory mount + // for this wrapper, it's an orphan (reload / failed mount): drop it and let + // the live-server's SSE re-inject the manifest if the session is still live. + if (wrapper.dataset.impeccablePreview === 'svelte-component' + && svelteComponentSession?.sessionId !== sessionId) { + wrapper.remove(); + if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } + + if (wrapper.dataset.impeccablePreview === 'svelte-component') { + if (!svelteComponentSession?.mountedVariant) { + return true; + } + currentSessionId = sessionId; + expectedVariants = Number(wrapper.dataset.impeccableVariantCount) + || Number(svelteComponentSession.manifest?.count) + || expectedVariants + || 1; + arrivedVariants = expectedVariants; + const saved = loadSession(); + applySavedSessionMeta(saved); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = svelteComponentSession.mountedVariant > 0 && svelteComponentSession.mountedVariant <= arrivedVariants + ? svelteComponentSession.mountedVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = resolveSvelteComponentAnchor() + || wrapper.parentElement; + state = 'CYCLING'; + hideShaderOverlay(); + showBar('cycling'); + startScrollTracking(); + refreshParamsPanel(); + saveSession(); + queueCheckpoint('browser_resumed_svelte_component'); + return true; + } + currentSessionId = sessionId; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -6002,6 +7297,7 @@ void main() { // Restore state from localStorage if available const saved = loadSession(); if (saved && saved.id === sessionId) { + applySavedSessionMeta(saved); visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0); if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; @@ -6072,9 +7368,9 @@ void main() { return true; } - // --------------------------------------------------------------------------- + // // Global bar (always visible at bottom) - // --------------------------------------------------------------------------- + // let globalBarEl = null; let globalBarBrandEl = null; @@ -6166,6 +7462,8 @@ void main() { let pageChatExpanded = false; let steerLocked = false; let steerRequestId = null; + let steerPendingMessage = ''; + let steerInputWasFocused = false; let pageChatDotsEl = null; let steerAwaitTimer = null; let voiceRecognition = null; @@ -6179,7 +7477,7 @@ void main() { const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; - const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect'; + const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect'; const GLOBAL_BAR_SECTION_GAP = 8; const GLOBAL_BAR_INNER_GAP = 2; const GLOBAL_BAR_INNER_PAD_LEFT = 2; @@ -6323,7 +7621,7 @@ void main() { const attempt = () => { steerFocusRecoverTimer = null; if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (pageHasHostTextSelection()) { steerFocusRecoverTimer = setTimeout(attempt, 120); return; @@ -6344,7 +7642,7 @@ void main() { steerFocusSuspended = true; steerFocusPauseUntil = performance.now() + STEER_PAGE_FOCUS_PAUSE_MS; pagePointerGesture = { x: e.clientX, y: e.clientY, dragged: false }; - if (pageChatInput && document.activeElement === pageChatInput) { + if (pageChatInput && activeElementDeep() === pageChatInput) { pageChatInput.blur(); } } @@ -6404,7 +7702,7 @@ void main() { pickActive, pageChatReady: !!pageChatInput, pageChatExpanded, - active: steerFocusTargetLabel(document.activeElement), + active: steerFocusTargetLabel(activeElementDeep()), shouldSteer: shouldFocusSteerChat(), ...(extra || {}), }); @@ -6423,26 +7721,26 @@ void main() { function focusConfigureInput(reason) { steerFocusLog('focusConfigureInput', { reason }); const inputId = configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input'; - const input = document.getElementById(inputId); + const input = uiGetById(inputId); if (!input) { steerFocusLog('focusConfigureInput missing', { reason }); return; } setTimeout(() => { - const before = document.activeElement; + const before = activeElementDeep(); input.focus(); steerFocusLog('focusConfigureInput result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== input, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== input, }); }, 60); } function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; - const focused = document.activeElement === pageChatInput; + const focused = activeElementDeep() === pageChatInput; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; const P = pageChatPalette(); pageChatEl.style.borderColor = steerLocked @@ -6476,15 +7774,15 @@ void main() { } syncPageChatVisual(); pageChatInput.style.pointerEvents = 'auto'; - const before = document.activeElement; + const before = activeElementDeep(); try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== pageChatInput, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== pageChatInput, }); } @@ -6515,6 +7813,37 @@ void main() { return wrap; } + function keepSteerPointerInside(e, opts = {}) { + e.stopPropagation(); + if (opts.preventDefault !== false) e.preventDefault(); + } + + function preparePageChatInputForTyping() { + if (!pageChatEl || !pageChatInput) return false; + pageChatExpanded = true; + pageChatEl.dataset.expanded = 'true'; + pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; + pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return true; + } + + function focusPageChatInput(reason) { + if (!preparePageChatInputForTyping() || steerLocked) return false; + try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } + const focused = activeElementDeep() === pageChatInput; + if (focused) steerInputWasFocused = true; + syncPageChatFocusRing(); + return focused; + } + function clearSteerAwaitTimer() { if (steerAwaitTimer) { clearTimeout(steerAwaitTimer); @@ -6528,6 +7857,7 @@ void main() { if (!steerLocked || steerRequestId !== id) return; unlockSteerChat({ error: 'Steer timed out waiting for the agent. Check that live-poll is running and replies with steer_done.', + restoreMessage: steerPendingMessage, }); }, STEER_AWAIT_TIMEOUT_MS); } @@ -6538,19 +7868,12 @@ void main() { steerLocked = true; pageChatEl.dataset.processing = 'true'; pageChatInput.disabled = true; - pageChatInput.value = ''; - pageChatInput.blur(); + preparePageChatInputForTyping(); if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = true; pageChatVoiceBtn.style.display = 'none'; } - pageChatExpanded = false; - pageChatEl.dataset.expanded = 'false'; - pageChatEl.style.width = PAGE_CHAT_PROCESSING_W; pageChatEl.style.cursor = 'default'; - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; pageChatInput.style.pointerEvents = 'none'; if (pageChatHint) { pageChatHint.style.display = 'none'; @@ -6568,17 +7891,26 @@ void main() { function unlockSteerChat(opts) { clearSteerAwaitTimer(); + const restoreMessage = typeof opts?.restoreMessage === 'string' ? opts.restoreMessage : ''; + const keepExpanded = Boolean(opts?.error && restoreMessage); steerLocked = false; + const completedId = steerRequestId; steerRequestId = null; if (!pageChatEl) return; pageChatEl.dataset.processing = 'false'; pageChatEl.removeAttribute('aria-busy'); pageChatEl.setAttribute('aria-label', 'Steer the page'); - pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W; + pageChatExpanded = keepExpanded; + pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false'; + pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W; pageChatEl.style.cursor = 'pointer'; if (pageChatInput) { pageChatInput.disabled = false; - pageChatInput.value = ''; + pageChatInput.value = keepExpanded ? restoreMessage : ''; + pageChatInput.style.width = keepExpanded ? '' : '0'; + pageChatInput.style.padding = keepExpanded ? '0 6px' : '0'; + pageChatInput.style.opacity = keepExpanded ? '1' : '0'; + pageChatInput.style.pointerEvents = 'auto'; } if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = false; @@ -6586,18 +7918,28 @@ void main() { } if (pageChatHint) { pageChatHint.textContent = 'Steer'; - pageChatHint.style.display = ''; - pageChatHint.style.visibility = ''; + pageChatHint.style.display = keepExpanded ? 'none' : ''; + pageChatHint.style.visibility = keepExpanded ? 'hidden' : ''; + pageChatHint.style.opacity = keepExpanded ? '0' : '1'; } if (pageChatDotsEl?.parentNode) { pageChatDotsEl.remove(); pageChatDotsEl = null; } + steerPendingMessage = keepExpanded ? restoreMessage : ''; + steerInputWasFocused = false; syncPageChatChrome(); syncPageChatFocusRing(); if (opts?.error) showToast(String(opts.error), 5000); else if (opts?.message) showToast(String(opts.message), 4000); - syncPageChatFocus('steer-unlock'); + if (completedId) { + sendSteerCheckpoint(completedId, opts?.error ? 'steer_error' : 'steer_done', { + message: opts?.message || opts?.error || '', + file: opts?.file || '', + }); + } + if (keepExpanded) focusPageChatInput('steer-error-restore'); + else syncPageChatFocus('steer-unlock'); } function steerSpeechRecognitionCtor() { @@ -6651,7 +7993,7 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = document.getElementById(PREFIX + '-configure-voice'); + const voiceBtn = uiGetById(PREFIX + '-configure-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -6784,7 +8126,7 @@ void main() { } function configureVoiceContext() { - const input = document.getElementById( + const input = uiGetById( configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input', ); return { @@ -6819,26 +8161,37 @@ void main() { if (!text || steerLocked) return; const id = id8(); steerRequestId = id; + steerPendingMessage = text; + if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); lockSteerChat(); scheduleSteerAwaitTimeout(id); + sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); sendEvent({ type: 'steer', id, message: text, pageUrl: location.href, }).then((res) => { - if (!res) unlockSteerChat({ error: 'Could not reach live server' }); + if (!res) { + sendSteerCheckpoint(id, 'steer_send_failed', { message: text }); + unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text }); + } }); } function maybeCompleteSteer(msg) { if (!steerRequestId || msg.id !== steerRequestId) return false; if (msg.type === 'steer_done') { - unlockSteerChat({ message: msg.message }); + unlockSteerChat({ message: msg.message, file: msg.file }); + if (msg.file && /\.svelte(?:$|\?)/.test(String(msg.file))) { + setTimeout(() => { + if (!steerLocked) showToast('Steer applied. Reload if the page has not refreshed yet.', 5000); + }, 4500); + } return true; } if (msg.type === 'error') { - unlockSteerChat({ error: msg.message || 'Steer failed' }); + unlockSteerChat({ error: msg.message || 'Steer failed', restoreMessage: steerPendingMessage }); return true; } return false; @@ -6847,21 +8200,10 @@ void main() { function expandPageChat(opts) { const focus = !opts || opts.focus !== false; if (!pageChatEl || !pageChatInput || steerLocked) return; - pageChatExpanded = true; - pageChatEl.dataset.expanded = 'true'; - pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; - pageChatEl.style.cursor = 'text'; - if (pageChatHint) { - pageChatHint.style.display = 'none'; - pageChatHint.style.opacity = '0'; - } - pageChatInput.style.width = ''; - pageChatInput.style.padding = '0 6px'; - pageChatInput.style.opacity = '1'; - pageChatInput.style.pointerEvents = 'auto'; + preparePageChatInputForTyping(); syncPageChatChrome(); syncPageChatFocusRing(); - if (focus) pageChatInput.focus(); + if (focus) focusPageChatInput('expand-page-chat'); } function collapsePageChat(opts) { @@ -6878,7 +8220,7 @@ void main() { } else { pageChatInput.style.pointerEvents = 'auto'; } - if (pageChatHint && document.activeElement !== pageChatInput) { + if (pageChatHint && activeElementDeep() !== pageChatInput) { pageChatHint.style.display = ''; pageChatHint.style.opacity = '1'; } @@ -6952,7 +8294,7 @@ void main() { pageChatEl.appendChild(pageChatInput); pageChatEl.appendChild(pageChatVoiceBtn); - if (!document.getElementById(PREFIX + '-page-chat-style')) { + if (!uiGetById(PREFIX + '-page-chat-style')) { const s = document.createElement('style'); s.id = PREFIX + '-page-chat-style'; s.textContent = @@ -6966,23 +8308,34 @@ void main() { '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } - pageChatEl.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { + keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat(); + expandPageChat({ focus: false }); + focusPageChatInput('page-chat-click'); }); - pageChatVoiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); + pageChatVoiceBtn.addEventListener('mousedown', keepSteerPointerInside); pageChatVoiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); + keepSteerPointerInside(e); if (steerLocked) return; toggleSteerVoice(); }); + pageChatInput.addEventListener('pointerdown', keepSteerPointerInside); + pageChatInput.addEventListener('mousedown', keepSteerPointerInside); + pageChatInput.addEventListener('click', (e) => { + keepSteerPointerInside(e); + if (!steerLocked) focusPageChatInput('page-chat-input-click'); + }); + pageChatInput.addEventListener('input', () => { syncPageChatVisual(); }); @@ -6995,7 +8348,7 @@ void main() { syncPageChatFocusRing(); setTimeout(() => { if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (!pageChatInput.value.trim()) collapsePageChat(); scheduleSteerFocusRecover('steer-blur-recover'); }, 120); @@ -7039,7 +8392,7 @@ void main() { globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false'; globalBarBrandEl.setAttribute('aria-label', connected ? 'Impeccable live mode' - : 'Impeccable live mode: agent not polling'); + : 'Impeccable live mode - agent not polling'); globalBarBrandEl.removeAttribute('title'); globalBarBrandEl.style.cursor = connected ? 'default' : 'help'; const mark = globalBarBrandEl.querySelector('[data-brand-mark]'); @@ -7077,7 +8430,7 @@ void main() { }); agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip'; agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP; - document.body.appendChild(agentPollTooltipEl); + uiAppend(agentPollTooltipEl); return agentPollTooltipEl; } @@ -7131,7 +8484,7 @@ void main() { // Custom focus-visible for bar buttons. Browser default is a heavy // blue ring that looks jarring on the dark capsule. Replace with a // soft accent-tinted inner ring that respects the bar's palette. - if (!document.getElementById(PREFIX + '-bar-focus-style')) { + if (!uiGetById(PREFIX + '-bar-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-bar-focus-style'; s.textContent = @@ -7143,7 +8496,7 @@ void main() { '@keyframes impeccable-agent-dot { 0%, 100% { opacity: 0.45; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1); } }' + '#' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: impeccable-agent-dot 1.4s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: none; opacity: 0.9; } }'; - document.head.appendChild(s); + uiAppendStyle(s); } globalBarEl = el('div', { @@ -7176,7 +8529,7 @@ void main() { brand.id = PREFIX + '-global-bar-brand'; brand.dataset.agentConnected = 'false'; brand.setAttribute('role', 'img'); - brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling'); + brand.setAttribute('aria-label', 'Impeccable live mode - agent not polling'); const brandMark = el('span', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -7211,7 +8564,7 @@ void main() { inner.id = PREFIX + '-global-bar-inner'; globalBarEl.appendChild(inner); - // --- button factory: icon-only at rest, label slides in on hover/active --- + // Button factory: icon-only at rest, label slides in on hover/active. function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { const b = el('button', { position: 'relative', @@ -7506,6 +8859,7 @@ void main() { color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', }); + exitBtn.id = PREFIX + '-exit'; exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = 'oklch(58% 0.15 35)'; exitBtn.style.background = P.exitHover; }); @@ -7530,8 +8884,8 @@ void main() { try { window.focus(); } catch { /* in-app preview may block */ } }, true); - document.body.appendChild(pendingDockEl); - document.body.appendChild(globalBarEl); + uiAppend(pendingDockEl); + uiAppend(globalBarEl); defangOutsideHandlers(pendingDockEl); defangOutsideHandlers(globalBarEl); @@ -7553,11 +8907,11 @@ void main() { } function updateGlobalBarState() { - const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); - const detectBadge = document.getElementById(PREFIX + '-detect-badge'); - const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); - const insertToggle = document.getElementById(PREFIX + '-insert-toggle'); - const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const detectToggle = uiGetById(PREFIX + '-detect-toggle'); + const detectBadge = uiGetById(PREFIX + '-detect-badge'); + const pickToggle = uiGetById(PREFIX + '-pick-toggle'); + const insertToggle = uiGetById(PREFIX + '-insert-toggle'); + const designToggle = uiGetById(PREFIX + '-design-toggle'); const theme = globalBarEl?.dataset.theme || 'light'; const P = barPaletteForTheme(theme); @@ -7751,8 +9105,9 @@ void main() { pendingApplyInFlight = false; } if (globalBarEl) { - globalBarEl.style.transform = 'translateY(100%)'; - setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300); + globalBarEl.style.transition = 'none'; + globalBarEl.remove(); + globalBarEl = null; } pageChatEl = null; pageChatInput = null; @@ -7765,6 +9120,7 @@ void main() { if (barEl) { barEl.remove(); barEl = null; } if (pickerEl) { pickerEl.remove(); pickerEl = null; } if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; } + if (editBadgeProxyRoot) { editBadgeProxyRoot.remove(); editBadgeProxyRoot = null; editBadgeProxyByTarget = new Map(); } if (evtSource) { evtSource.close(); evtSource = null; } document.removeEventListener('mousemove', handleMouseMove, true); document.removeEventListener('click', handleClick, true); @@ -7777,9 +9133,9 @@ void main() { console.log('[impeccable] Live mode exited.'); } - // --------------------------------------------------------------------------- + // // Design System Panel - visualizes the project's .impeccable/design.json sidecar - // --------------------------------------------------------------------------- + // const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; const DESIGN_PANEL_WIDTH = 440; @@ -7847,7 +9203,7 @@ void main() { root.className = 'root'; designShadow.appendChild(root); - document.body.appendChild(designHost); + uiAppend(designHost); // The host is pointer-events: none; the panel inside the shadow DOM // manages its own auto/none. Events bubble through the shadow boundary, // so attaching here silences host-page outside-interaction handlers @@ -7889,7 +9245,7 @@ void main() { .root * { box-sizing: border-box; } button { font: inherit; color: inherit; } - /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + /* Panel shell: chrome matches the bar; body canvas stays neutral */ .panel { position: fixed; top: 12px; bottom: 72px; right: 12px; width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); @@ -7955,7 +9311,7 @@ void main() { .panel-body::-webkit-scrollbar { width: 8px; } .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } - /* --- States --- */ + /* States */ .empty, .loading, .error { margin: 16px 4px; padding: 28px 20px; text-align: center; @@ -7966,7 +9322,7 @@ void main() { .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; } .error { color: oklch(45% 0.15 25); } - /* --- Stale hint --- */ + /* Stale hint */ .stale { display: flex; align-items: center; gap: 8px; margin: 8px 4px 12px; @@ -7979,7 +9335,7 @@ void main() { .stale-text { flex: 1; min-width: 0; } .stale-text strong { color: ${DP.ink}; font-weight: 600; } - /* --- Parsed-md fallback banner --- */ + /* Parsed-md fallback banner */ .parsed-md-cta { margin: 8px 4px 14px; padding: 14px 16px; @@ -7991,7 +9347,7 @@ void main() { .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; } .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; } - /* --- Tile primitives --- */ + /* Tile primitives */ .tile { position: relative; background: ${DP.tile}; @@ -8010,7 +9366,7 @@ void main() { } .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; } - /* --- Color tile --- */ + /* Color tile */ .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; } .c-tile:hover { transform: translateY(-1px); } .c-hero { @@ -8025,7 +9381,7 @@ void main() { .c-ramp > span { flex: 1; } .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Type tile --- */ + /* Type tile */ .t-tile { } .t-specimen { margin: 4px 0 6px; @@ -8035,7 +9391,7 @@ void main() { .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; } .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Shadow tile --- */ + /* Shadow tile */ .s-tile { } .s-surface { height: 60px; margin: 8px 2px 10px; @@ -8045,14 +9401,14 @@ void main() { .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; } .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; } - /* --- Radii strip --- */ + /* Radii strip */ .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; } .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; } .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); } .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; } .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; } - /* --- Component tile (hosts live primitives) --- */ + /* Component tile (hosts live primitives) */ .cmp-tile { } .cmp-stage { margin: 12px -4px 0; @@ -8066,7 +9422,7 @@ void main() { .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; } .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; } - /* --- Collapsible --- */ + /* Collapsible */ .coll { margin: 0 4px 8px; background: ${DP.tile}; @@ -8131,7 +9487,7 @@ void main() { .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; } .coll .overview-body li { margin-bottom: 3px; } - /* --- raw tab markdown (unchanged layout, neutralized palette) --- */ + /* raw tab markdown (unchanged layout, neutralized palette) */ .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; } .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; } .md h1 { font-size: 18px; } @@ -8303,7 +9659,7 @@ void main() { return box; } - // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 --- + // Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 function renderDesignVisual(body, parsed, sidecar) { const frontmatter = parsed?.frontmatter || {}; @@ -8667,7 +10023,7 @@ void main() { return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components'); } - // --- Collapsibles --------------------------------------------------------- + // Collapsibles. function buildCollapsible(key, label, count) { const wrap = document.createElement('div'); @@ -8775,7 +10131,7 @@ void main() { return s.replace(/\s+#.*$/, '').trim(); } - // --- Raw tab: minimal markdown renderer (subset) -------------------------- + // Raw tab: minimal markdown renderer (subset) function renderRawTab(body, md) { const wrap = document.createElement('div'); @@ -8908,9 +10264,9 @@ void main() { } catch { /* ignore */ } } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // function init() { try { history.scrollRestoration = 'manual'; } catch {} diff --git a/.kiro/skills/impeccable/scripts/live-completion.mjs b/.kiro/skills/impeccable/scripts/live-completion.mjs index 86b637fff..986773066 100644 --- a/.kiro/skills/impeccable/scripts/live-completion.mjs +++ b/.kiro/skills/impeccable/scripts/live-completion.mjs @@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.mode === 'error') return 'error'; + if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error'; return 'agent_done'; } diff --git a/.kiro/skills/impeccable/scripts/live-inject.mjs b/.kiro/skills/impeccable/scripts/live-inject.mjs index b9d3df41d..3a1f36e46 100644 --- a/.kiro/skills/impeccable/scripts/live-inject.mjs +++ b/.kiro/skills/impeccable/scripts/live-inject.mjs @@ -17,11 +17,38 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './impeccable-paths.mjs'; +import { + applySvelteKitLiveAdapter, + detectSvelteKitProject, + removeSvelteKitLiveAdapter, +} from './live-sveltekit-adapter.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start'; +const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end'; + +export const LIVE_IGNORE_PATTERNS = Object.freeze([ + '.impeccable/hook.cache.json', + '.impeccable/live/server.json', + '.impeccable/live/sessions/', + '.impeccable/live/previews/', + '.impeccable/live/annotations/', + '.impeccable/live/cache/', + '.impeccable/live/manual-edit-apply-transaction.json', + '.impeccable/live/manual-edit-events.jsonl', + '.impeccable/live/manual-edit-evidence/', + '.impeccable/live/pending-manual-edits.json', + '.impeccable/live/deferred-svelte-component-accepts.json', + '.impeccable-live.json', + '.impeccable-live/', + 'node_modules/.impeccable-live/', + 'src/lib/impeccable/ImpeccableLiveRoot.svelte', + 'src/lib/impeccable/__runtime.js', + 'src/lib/impeccable/[0-9a-f]*/', +]); /** * Hard-excluded directory patterns. These are NEVER user-facing pages and @@ -83,8 +110,14 @@ Output (JSON): validateConfig(config); const resolvedFiles = resolveFiles(process.cwd(), config); + const svelteKit = detectSvelteKitProject(process.cwd(), config); if (args.includes('--remove')) { + if (svelteKit) { + const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config }); + console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; @@ -110,6 +143,13 @@ Output (JSON): console.error(JSON.stringify({ ok: false, error: 'missing_port' })); process.exit(1); } + const gitIgnore = ensureLiveGitIgnores(process.cwd()); + + if (svelteKit) { + const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config }); + console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); @@ -129,10 +169,68 @@ Output (JSON): }; }); const anyInserted = results.some((r) => r.inserted); - console.log(JSON.stringify({ ok: anyInserted, port, results })); + console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results })); if (!anyInserted) process.exit(1); } +export function ensureLiveGitIgnores(cwd = process.cwd()) { + const target = resolveIgnoreTarget(cwd); + const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : ''; + const block = [ + IGNORE_MARKER_OPEN, + ...LIVE_IGNORE_PATTERNS, + IGNORE_MARKER_CLOSE, + ].join('\n'); + const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`); + + let updated; + if (markerRe.test(existing)) { + updated = existing.replace(markerRe, block); + } else { + const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n'; + updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`; + } + + if (updated !== existing) { + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + fs.writeFileSync(target.path, updated, 'utf-8'); + } + + return { + file: path.relative(cwd, target.path).split(path.sep).join('/'), + mode: target.mode, + changed: updated !== existing, + patterns: [...LIVE_IGNORE_PATTERNS], + }; +} + +function resolveIgnoreTarget(cwd) { + const gitExcludePath = resolveGitInfoExcludePath(cwd); + if (gitExcludePath) { + return { path: gitExcludePath, mode: 'git-info-exclude' }; + } + return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' }; +} + +function resolveGitInfoExcludePath(cwd) { + const dotGit = path.join(cwd, '.git'); + if (!fs.existsSync(dotGit)) return null; + + const stat = fs.statSync(dotGit); + if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude'); + if (!stat.isFile()) return null; + + const body = fs.readFileSync(dotGit, 'utf-8').trim(); + const match = body.match(/^gitdir:\s*(.+)$/i); + if (!match) return null; + const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]); + return path.join(gitDir, 'info', 'exclude'); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Expand config.files (which may contain glob patterns) into a literal list * of existing file paths relative to rootDir. Literal entries pass through; diff --git a/.kiro/skills/impeccable/scripts/live-insert.mjs b/.kiro/skills/impeccable/scripts/live-insert.mjs index 09d4d55be..0658e9914 100644 --- a/.kiro/skills/impeccable/scripts/live-insert.mjs +++ b/.kiro/skills/impeccable/scripts/live-insert.mjs @@ -21,6 +21,11 @@ import { buildCssAuthoring, buildCssSelectorPrefixExamples, } from './live-wrap.mjs'; +import { + buildSvelteComponentCssAuthoring, + scaffoldSvelteComponentInsertSession, + shouldUseSvelteComponentInjection, +} from './live-svelte-component.mjs'; const INSERT_POSITIONS = new Set(['before', 'after']); @@ -192,6 +197,41 @@ Output (JSON): const styleMode = detectStyleMode(targetFile); const isJsx = commentSyntax.open === '{/*'; const spliceIndex = computeInsertLine(startLine, endLine, position); + const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); + + if (shouldUseSvelteComponentInjection(targetFile)) { + const session = scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile: relTargetFile, + insertLine: spliceIndex + 1, + position, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + anchorLines: lines.slice(startLine, endLine + 1), + cwd: process.cwd(), + }); + console.log(JSON.stringify({ + mode: 'insert', + position, + file: session.manifestFile, + sourceFile: relTargetFile, + previewMode: 'svelte-component', + componentDir: session.componentDir, + propContract: session.propContract, + insertLine: 1, + sourceInsertLine: spliceIndex + 1, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + commentSyntax, + styleMode: 'svelte-component', + styleTag: null, + cssSelectorPrefixExamples: [], + cssAuthoring: buildSvelteComponentCssAuthoring(count), + })); + return; + } + const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? ''; @@ -216,7 +256,7 @@ Output (JSON): console.log(JSON.stringify({ mode: 'insert', position, - file: path.relative(process.cwd(), targetFile), + file: relTargetFile, insertLine: insertLine + 1, commentSyntax, styleMode: styleMode.mode, diff --git a/.kiro/skills/impeccable/scripts/live-poll.mjs b/.kiro/skills/impeccable/scripts/live-poll.mjs index fad836612..1e1259fbc 100644 --- a/.kiro/skills/impeccable/scripts/live-poll.mjs +++ b/.kiro/skills/impeccable/scripts/live-poll.mjs @@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs'; // that ceiling and loop in `pollOnce` to synthesize a long poll without // depending on the standalone undici package. export const PER_REQUEST_TIMEOUT_MS = 270_000; +export const DEFAULT_EVENT_LEASE_MS = 600_000; const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); @@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) { ? totalDeadline - Date.now() : PER_REQUEST_TIMEOUT_MS; const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); - const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); + const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`); if (res.status === 401) { const err = new Error('Authentication failed. The server token may have changed.'); @@ -317,7 +318,7 @@ Modes: Options: --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) - --file PATH Attach a source file path to the reply (generate flow) + --file PATH Attach a source file path to the reply (generate/steer flow) --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --help Show this help message diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs index 16c8285b9..cd1091b88 100644 --- a/.kiro/skills/impeccable/scripts/live-server.mjs +++ b/.kiro/skills/impeccable/scripts/live-server.mjs @@ -42,6 +42,10 @@ import { } from './live-manual-edits-buffer.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs'; +import { + applyDeferredSvelteComponentAccepts, + removeAllSvelteComponentSessions, +} from './live-svelte-component.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated @@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; +const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2; const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); function tombstoneTimedOutApplyId(eventId, details = {}) { @@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) { return entry.event; } entry.leaseUntil = Date.now() + leaseMs; + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return entry.event; } @@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) { const acknowledged = state.pendingEvents[idx].event; state.pendingEvents.splice(idx, 1); scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return acknowledged; } +function findPendingEventById(id) { + if (!id) return null; + const entry = state.pendingEvents.find((item) => item.event?.id === id); + return entry?.event || null; +} + function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; return `live-poll.mjs --reply ${id} done --data ''`; @@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) { return summary; } +function summarizeActiveSessionForClient(snapshot = {}) { + return { + id: snapshot.id, + phase: snapshot.phase, + pageUrl: snapshot.pageUrl ?? null, + sourceFile: snapshot.sourceFile ?? null, + previewFile: snapshot.previewFile ?? null, + previewMode: snapshot.previewMode ?? null, + expectedVariants: snapshot.expectedVariants ?? 0, + arrivedVariants: snapshot.arrivedVariants ?? 0, + visibleVariant: snapshot.visibleVariant ?? null, + checkpointRevision: snapshot.checkpointRevision ?? 0, + paramValues: snapshot.paramValues || {}, + }; +} + +function activeSessionSummaries() { + if (!state.sessionStore) return []; + return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot)); +} + +function cancelQueuedAnonymousExitEvents() { + let removed = 0; + for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) { + const event = state.pendingEvents[i]?.event; + if (event?.type !== 'exit' || event.id) continue; + state.pendingEvents.splice(i, 1); + removed += 1; + } + if (removed > 0) { + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); + } + return removed; +} + function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { const canceledById = new Map(); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); @@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() { clearTimeout(state.leaseTimer); state.leaseTimer = null; } - if (state.pendingPolls.length === 0) return; const now = Date.now(); const nextLeaseUntil = state.pendingEvents .map((entry) => entry.leaseUntil || 0) @@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() { state.leaseTimer = setTimeout(() => { state.leaseTimer = null; flushPendingPolls(); - }, Math.max(0, nextLeaseUntil - now)); + broadcastAgentPollingIfChanged(); + }, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS)); } function flushPendingPolls() { @@ -1032,7 +1082,9 @@ function flushPendingPolls() { } function agentPollingConnected() { - return state.pendingPolls.length > 0; + const now = Date.now(); + return state.pendingPolls.length > 0 + || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now); } function broadcastAgentPollingIfChanged() { @@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/status') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } - const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; + const sessions = activeSessionSummaries(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', @@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/events' && req.method === 'GET') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + clearTimeout(state.exitTimer); + state.exitTimer = null; + cancelQueuedAnonymousExitEvents(); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { type: 'connected', hasProjectContext: hasProjectContext(), agentPolling: agentPollingConnected(), + activeSessions: activeSessionSummaries(), }) + '\n\n'); state.sseClients.add(res); - clearTimeout(state.exitTimer); // Keepalive: SSE comment every 30s prevents silent connection drops. const heartbeat = setInterval(() => { @@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } } + if (msg.type === 'exit') { + cleanupSvelteComponentSessionsBeforeExit(); + } if (msg.type !== 'checkpoint') { enqueueEvent(msg); } @@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) { }); } +function sessionFileMetadataFromPollReply(file) { + if (!file || typeof file !== 'string') return { file }; + const normalized = file.split(path.sep).join('/'); + const base = { file: normalized }; + if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base; + if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base; + + let full; + try { + full = path.resolve(process.cwd(), normalized); + const rel = path.relative(process.cwd(), full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base; + } catch { + return base; + } + + try { + const manifest = JSON.parse(fs.readFileSync(full, 'utf-8')); + if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base; + return { + file: String(manifest.sourceFile).split(path.sep).join('/'), + sourceFile: String(manifest.sourceFile).split(path.sep).join('/'), + previewFile: normalized, + previewMode: 'svelte-component', + }; + } catch { + return base; + } +} + function handlePollPost(req, res) { let body = ''; req.on('data', (c) => { body += c; }); @@ -1965,6 +2053,16 @@ function handlePollPost(req, res) { res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); return; } + const pendingEventBeforeAck = findPendingEventById(msg.id); + if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done' + && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'steer_done_requires_file_or_message', + hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.', + })); + return; + } const acknowledgedEvent = acknowledgePendingEvent(msg.id); let skipJournalReply = false; let existingSession = null; @@ -1987,6 +2085,7 @@ function handlePollPost(req, res) { })); return; } + const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); if (state.sessionStore && msg.id && !skipJournalReply) { try { const eventType = msg.type === 'steer_done' @@ -2001,7 +2100,10 @@ function handlePollPost(req, res) { state.sessionStore.appendEvent({ type: eventType, id: msg.id, - file: msg.file, + file: replyFileMeta.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, message: msg.message, sourceEventType: acknowledgedEvent?.type, carbonize: msg.data?.carbonize === true, @@ -2010,7 +2112,16 @@ function handlePollPost(req, res) { } flushPendingPolls(); // Forward the reply to the browser via SSE - broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + broadcast({ + type: msg.type || 'done', + id: msg.id, + message: msg.message, + file: msg.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, + data: msg.data, + }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); @@ -2023,6 +2134,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { + cleanupSvelteComponentSessionsBeforeExit(); removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; @@ -2037,6 +2149,25 @@ function shutdown() { process.exit(0); } +function cleanupSvelteComponentSessionsBeforeExit() { + try { + removeAllSvelteComponentSessions(process.cwd()); + } catch (err) { + console.warn('[impeccable] Svelte component session cleanup failed:', err.message); + } +} + +function applyLegacyDeferredAcceptsOnStartup() { + try { + const result = applyDeferredSvelteComponentAccepts(process.cwd()); + if (result.applied > 0 || result.failed > 0) { + console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result)); + } + } catch (err) { + console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({ cwd: process.cwd(), reason: 'manual_edit_server_start_recovered_abandoned_transaction', }); +applyLegacyDeferredAcceptsOnStartup(); restorePendingEventsFromStore(); pruneStaleManualApplyEvidence(process.cwd()); const portArg = args.find(a => a.startsWith('--port=')); diff --git a/.kiro/skills/impeccable/scripts/live-session-store.mjs b/.kiro/skills/impeccable/scripts/live-session-store.mjs index 7562e3d5b..5ec4d34d4 100644 --- a/.kiro/skills/impeccable/scripts/live-session-store.mjs +++ b/.kiro/skills/impeccable/scripts/live-session-store.mjs @@ -106,6 +106,8 @@ function baseSnapshot(id) { phase: 'new', pageUrl: null, sourceFile: null, + previewFile: null, + previewMode: null, expectedVariants: 0, arrivedVariants: 0, visibleVariant: null, @@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; - next.sourceFile = event.file ?? next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0); next.pendingEventSeq = null; next.pendingEvent = null; if (event.carbonize === true) { @@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { } break; case 'checkpoint': + if (COMPLETED_PHASES.has(next.phase)) { + next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null }); + break; + } if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { next.phase = event.phase ?? next.phase; next.checkpointRevision = event.revision ?? next.checkpointRevision; next.activeOwner = event.owner ?? next.activeOwner; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.visibleVariant = event.visibleVariant ?? next.visibleVariant; + next.sourceFile = event.sourceFile ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; if (event.paramValues) next.paramValues = { ...event.paramValues }; } else { next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); @@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'steer_done': next.phase = 'steer_done'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.message = event.message ?? next.message; next.pendingEventSeq = null; next.pendingEvent = null; break; @@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'complete': next.phase = 'completed'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; next.pendingEventSeq = null; next.pendingEvent = null; break; diff --git a/.kiro/skills/impeccable/scripts/live-svelte-component.mjs b/.kiro/skills/impeccable/scripts/live-svelte-component.mjs new file mode 100644 index 000000000..dc35dc0ce --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live-svelte-component.mjs @@ -0,0 +1,826 @@ +/** + * Svelte live-mode component injection helpers. + * + * Variants are real .svelte components under node_modules/.impeccable-live//. + * The browser mounts them via Svelte 5 mount(); accept inlines the chosen + * variant back into the route source with props mapped to original bindings. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; + +export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live'; +export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`; +export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json'; + +const MUSTACHE_RE = /\{([^{}]+)\}/g; + +export function shouldUseSvelteComponentInjection(filePath) { + if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false; + return path.extname(filePath).toLowerCase() === '.svelte'; +} + +export function componentSessionDir(id, cwd = process.cwd()) { + return path.join(cwd, SVELTE_COMPONENT_ROOT, id); +} + +export function manifestPathForSession(id, cwd = process.cwd()) { + return path.join(componentSessionDir(id, cwd), 'manifest.json'); +} + +export function ensureRuntimeHelper(cwd = process.cwd()) { + const file = path.join(cwd, SVELTE_RUNTIME_FILE); + if (fs.existsSync(file)) return file; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8'); + return file; +} + +/** + * Extract ordered unique mustache expressions from markup (not inside ). + */ +export function extractMustacheExpressions(text) { + const expressions = []; + const seen = new Set(); + const lines = String(text || '').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('\n` + : ''; + return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n\n`; +} + +function buildInsertVariantStub(variantNum) { + return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; +} + +export function scaffoldSvelteComponentSession({ + id, + count, + sourceFile, + sourceStartLine, + sourceEndLine, + originalLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const originalMarkup = originalLines.join('\n'); + const contract = buildPropContract(extractMustacheExpressions(originalMarkup)); + const originalWithProps = substituteExprsWithProps(originalMarkup, contract); + + const manifest = { + id, + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + sourceStartLine, + sourceEndLine, + count, + propContract: contract, + originalMarkup, + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: contract, + }; +} + +export function scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile, + insertLine, + position, + anchorStartLine, + anchorEndLine, + anchorLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const anchorMarkup = (anchorLines || []).join('\n'); + const manifest = { + id, + mode: 'insert', + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + insertLine, + position, + anchorStartLine, + anchorEndLine, + originalMarkup: anchorMarkup, + anchorMarkup, + count, + propContract: [], + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: [], + }; +} + +export function findSvelteComponentManifest(id, cwd = process.cwd()) { + const direct = manifestPathForSession(id, cwd); + if (fs.existsSync(direct)) { + return readManifest(direct); + } + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return null; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(root, entry.name, 'manifest.json'); + if (!fs.existsSync(candidate)) continue; + try { + const manifest = readManifest(candidate); + if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; + } catch { /* skip */ } + } + return null; +} + +export function readManifest(manifestPath) { + const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + return { + ...data, + manifestPath, + }; +} + +export function resolveSourceFile(sourceFile, cwd = process.cwd()) { + if (!sourceFile || path.isAbsolute(sourceFile)) { + throw new Error('Invalid svelte-component source file'); + } + const full = path.resolve(cwd, sourceFile); + const rel = path.relative(cwd, full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('Svelte-component source file escapes project root'); + } + if (!fs.existsSync(full)) { + throw new Error('Svelte-component source file not found: ' + sourceFile); + } + return full; +} + +function appendCssToSvelteStyle(lines, cssLines) { + const closeIdx = findLastStyleCloseLine(lines); + const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; + if (closeIdx === -1) { + return [...lines, '', '']; + } + return [ + ...lines.slice(0, closeIdx), + ...prepared, + ...lines.slice(closeIdx), + ]; +} + +function findLastStyleCloseLine(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + if (/<\/style\s*>/.test(lines[i])) return i; + } + return -1; +} + +function bakeParamValuesInCss(cssLines, paramValues) { + if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; + return cssLines.map((line) => { + let out = line; + for (const [key, value] of Object.entries(paramValues)) { + const varName = `--p-${key}`; + out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); + } + return out; + }); +} + +function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { + const css = String((cssLines || []).join('\n')); + if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; + + const rules = parseCssRules(css); + const output = []; + for (const rule of rules) { + appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); + } + return output.join('\n') + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ''); +} + +function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { + const prelude = rule.prelude.trim(); + const body = rule.body.trim(); + if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; + + if (/^@scope\b/i.test(prelude)) { + if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; + const inner = parseCssRules(body); + for (const innerRule of inner) { + const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); + if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; + output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); + } + return; + } + + const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); + if (!rewrittenPrelude) return; + output.push(formatCssRule(rewrittenPrelude, body)); +} + +function parseCssRules(css) { + const rules = []; + const text = String(css || ''); + let i = 0; + while (i < text.length) { + while (i < text.length && /\s/.test(text[i])) i++; + const preludeStart = i; + while (i < text.length && text[i] !== '{') i++; + if (i >= text.length) break; + const prelude = text.slice(preludeStart, i).trim(); + i++; + const bodyStart = i; + let depth = 1; + let quote = null; + let comment = false; + while (i < text.length && depth > 0) { + const ch = text[i]; + const next = text[i + 1]; + if (comment) { + if (ch === '*' && next === '/') { + comment = false; + i += 2; + continue; + } + i++; + continue; + } + if (quote) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === quote) quote = null; + i++; + continue; + } + if (ch === '/' && next === '*') { + comment = true; + i += 2; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); + if (prelude) rules.push({ prelude, body }); + } + return rules; +} + +function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { + const selectors = splitSelectorList(prelude); + const rewritten = []; + for (const selector of selectors) { + const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); + if (next) rewritten.push(next); + } + return rewritten.join(', '); +} + +function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { + let out = selector.trim(); + const hasVariant = /data-impeccable-variant/.test(out); + if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; + if (hasVariant) { + out = out.replace(variantSelectorRegex(variantNum), ''); + out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); + } + + const paramResult = rewriteParamSelectors(out, paramValues); + if (!paramResult.keep) return ''; + out = paramResult.selector; + + out = out + .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') + .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') + .replace(/\s+/g, ' ') + .trim(); + + out = out.replace(/^[>+~]\s*/, '').trim(); + if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; + return out; +} + +function rewriteParamSelectors(selector, paramValues) { + let keep = true; + const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { + if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; + const actual = paramValues[key]; + if (expected != null && String(actual) !== String(expected)) { + keep = false; + return ''; + } + if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { + keep = false; + return ''; + } + return ''; + }); + return { keep, selector: next }; +} + +function splitSelectorList(prelude) { + const selectors = []; + let start = 0; + let bracket = 0; + let paren = 0; + let quote = null; + for (let i = 0; i < prelude.length; i++) { + const ch = prelude[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '[') bracket++; + else if (ch === ']') bracket = Math.max(0, bracket - 1); + else if (ch === '(') paren++; + else if (ch === ')') paren = Math.max(0, paren - 1); + else if (ch === ',' && bracket === 0 && paren === 0) { + selectors.push(prelude.slice(start, i)); + start = i + 1; + } + } + selectors.push(prelude.slice(start)); + return selectors; +} + +function selectorHasVariant(selector, variantNum) { + return variantSelectorRegex(variantNum).test(selector); +} + +function variantSelectorRegex(variantNum) { + return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); +} + +function formatCssRule(selector, body) { + return `${selector} { ${body.trim()} }`; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { + const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); + const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); + const resultBase = { + file: manifest.sourceFile, + sourceFile: manifest.sourceFile, + previewMode: 'svelte-component', + componentDir: manifest.componentDir, + carbonize: false, + }; + if (!fs.existsSync(variantPath)) { + return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; + } + + const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); + if (manifest.mode === 'insert') { + return inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, + }); + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const contract = manifest.propContract || []; + const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); + const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract) + .split('\n') + .map((line) => line.trimEnd()); + + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const start = Number(manifest.sourceStartLine) - 1; + const end = Number(manifest.sourceEndLine) - 1; + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { + return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; + } + + const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, start), + ...indentedMarkup, + ...sourceLines.slice(end + 1), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, +}) { + if (!svelteMarkupHasVisibleContent(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; + } + if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const restoredMarkup = String(markup || '') + .split('\n') + .map((line) => line.trimEnd()); + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const insertIndex = Number(manifest.insertLine) - 1; + if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { + return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; + } + + const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; + const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, insertIndex), + ...indentedMarkup, + ...sourceLines.slice(insertIndex), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function svelteMarkupHasVisibleContent(markup) { + const text = String(markup || '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 0) return true; + return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); +} + +function mergeOriginalTopLevelAttrs(markup, originalMarkup) { + const variantOpen = matchOpeningTag(markup); + const originalOpen = matchOpeningTag(originalMarkup); + if (!variantOpen || !originalOpen) return markup; + if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; + + const variantAttrs = parseAttrSegments(variantOpen.attrs); + const originalAttrs = parseAttrSegments(originalOpen.attrs); + const additions = []; + let attrs = variantOpen.attrs; + + const originalClass = originalAttrs.get('class'); + const variantClass = variantAttrs.get('class'); + if (originalClass && variantClass) { + const merged = mergeStaticClassAttr(originalClass, variantClass); + if (merged) { + attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); + variantAttrs.set('class', { ...variantClass, raw: merged }); + } + } else if (originalClass && !variantClass) { + additions.push(originalClass.raw); + } + + for (const [name, attr] of originalAttrs) { + if (name === 'class') continue; + if (!variantAttrs.has(name)) additions.push(attr.raw); + } + + if (additions.length === 0 && attrs === variantOpen.attrs) return markup; + const nextOpen = variantOpen.prefix + + variantOpen.tag + + attrs + + additions.map((attr) => ' ' + attr.trim()).join('') + + variantOpen.close; + return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); +} + +function matchOpeningTag(markup) { + const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); + if (!match) return null; + return { + raw: match[0], + prefix: match[1], + tag: match[2], + attrs: match[3] || '', + close: match[4], + index: match.index || 0, + }; +} + +function parseAttrSegments(attrs) { + const out = new Map(); + const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; + let match; + while ((match = re.exec(attrs))) { + const raw = match[0]; + const name = match[1]; + out.set(name, { + name, + raw, + start: match.index, + end: match.index + raw.length, + }); + } + return out; +} + +function mergeStaticClassAttr(originalClass, variantClass) { + const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + if (!originalValue || !variantValue) return null; + const quote = variantValue[1]; + const classes = [ + ...variantValue[2].split(/\s+/), + ...originalValue[2].split(/\s+/), + ].filter(Boolean); + return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; +} + +export function removeSvelteComponentSession(id, cwd = process.cwd()) { + const dir = componentSessionDir(id, cwd); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* non-fatal */ } +} + +export function removeAllSvelteComponentSessions(cwd = process.cwd()) { + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('__')) continue; + try { + fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + } catch { /* non-fatal */ } + } +} + +export function deferredAcceptsPath(cwd = process.cwd()) { + const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16); + return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json'); +} + +export function readDeferredAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch { + return { accepts: [] }; + } +} + +export function writeDeferredAccept(entry, cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const data = readDeferredAccepts(cwd); + data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id); + data.accepts.push({ ...entry, createdAt: new Date().toISOString() }); + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + const data = readDeferredAccepts(cwd); + const pending = Array.isArray(data.accepts) ? data.accepts : []; + const results = []; + const remaining = []; + for (const entry of pending) { + try { + const manifest = findSvelteComponentManifest(entry.id, cwd); + if (!manifest) { + results.push({ id: entry.id, ok: false, error: 'manifest not found' }); + remaining.push(entry); + continue; + } + const result = inlineSvelteComponentAccept( + manifest, + entry.variantNum, + entry.paramValues || null, + cwd, + ); + results.push({ id: entry.id, ok: result.handled !== false, result }); + if (result.handled === false) remaining.push(entry); + } catch (err) { + results.push({ id: entry.id, ok: false, error: err.message }); + remaining.push(entry); + } + } + if (remaining.length > 0) { + fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8'); + } else { + try { fs.rmSync(file, { force: true }); } catch {} + } + return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results }; +} + +export function buildSvelteComponentCssAuthoring(count) { + const variantNumbers = Array.from({ length: count }, (_, i) => i + 1); + return { + mode: 'svelte-component', + styleTag: null, + strategy: 'component-style-block', + rulePattern: '.semantic-class { ... }', + selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'), + requirements: [ + 'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).', + 'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.', + 'Put variant CSS in the component ' : '')); + if (paramValues && Object.keys(paramValues).length > 0) { + lines.push( + bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close, + ); + } + lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + lines.push(bodyIndent + '
'); + lines.push(...bodyRestored); + lines.push(bodyIndent + '
'); + }; + + if (isJsx) { + const wrapperStyle = 'style={{ display: "contents" }}'; + lines.push(indent + '
'); + pushCarbonizeBody(indent + ' '); + lines.push(indent + '
'); + } else { + pushCarbonizeBody(indent); + } + + return lines; +} + +function reindentContent(contentLines, fromIndent, toIndent) { + return contentLines.map((line) => { + if (line.trim() === '') return ''; + if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length); + return toIndent + line.trimStart(); + }); +} + function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; @@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const needsCarbonize = !!(cssContent || hasHelperAttrs); - // Build the replacement const restored = deindentContent(variantContent, indent); - const replacement = []; - - if (cssContent) { - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); - // JSX targets need the CSS body wrapped in a template literal so that the - // `{` and `}` in CSS rules don't get parsed as JSX expressions. - replacement.push(indent + '' : '')); - if (paramValues && Object.keys(paramValues).length > 0) { - // Preserve the user's knob positions for the carbonize-cleanup agent - // to bake into the final CSS when it collapses scoped rules. - replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); - } - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); - } - - // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the - // carbonize CSS block working visually by re-wrapping the accepted content - // in a data-impeccable-variant="N" div with `display: contents` (so layout - // isn't affected). The carbonize agent strips this attribute + wrapper when - // it moves the CSS to a proper stylesheet. - // - // Style attribute syntax has to follow the host file's flavor — JSX files - // need the object form, otherwise React 19 throws "Failed to set indexed - // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. - if (cssContent) { - const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; - replacement.push(indent + '
'); - replacement.push(...restored); - replacement.push(indent + '
'); - } else { - replacement.push(...restored); - } + const replacement = buildCarbonizeReplacement({ + indent, + commentSyntax, + isJsx, + id, + variantNum, + cssContent, + paramValues, + restored, + }); const newLines = [ ...lines.slice(0, replaceRange.start), @@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; } +function readSourceShadowPreviewMeta(content, id) { + const escaped = escapeRegExp(id); + const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>'); + const match = String(content || '').match(wrapperRe); + if (!match) return null; + const tag = match[0]; + if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null; + const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file'); + const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start')); + const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end')); + if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null; + return { sourceFile, sourceStartLine, sourceEndLine }; +} + +function readHtmlAttr(tag, name) { + const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1')); + if (!match) return null; + return decodeHtmlAttr(match[2]); +} + +function decodeHtmlAttr(value) { + return String(value || '') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- @@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts }; diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index f609ac847..d7c580e2f 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -26,9 +26,9 @@ return; } - // --------------------------------------------------------------------------- + // // Design tokens - // --------------------------------------------------------------------------- + // // Brand kinpaku (gold) is pinned to the site's neo-kinpaku tokens // (see site/styles/kinpaku-tokens.css) so Accept / knobs / cycle-dots / @@ -115,19 +115,48 @@ { value: 'overdrive', label: 'Overdrive' }, ]; - // --------------------------------------------------------------------------- + const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions']; + const LIVE_UI_SURFACES = [ + { 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'] }, + { key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] }, + { key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-configure-input-wrap', PREFIX + '-input', PREFIX + '-configure-voice'] }, + { key: 'action-picker', ids: [PREFIX + '-picker'] }, + { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, + { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, + { key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] }, + { key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] }, + { key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] }, + { 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'] }, + { key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] }, + { key: 'design-system-panel', ids: [PREFIX + '-design-host'] }, + { key: 'toasts-and-errors', ids: [PREFIX + '-toast'] }, + { key: 'css-isolation-boundary', ids: [PREFIX + '-root'] }, + ]; + const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))]; + + // // State - // --------------------------------------------------------------------------- + // let state = 'IDLE'; let hoveredElement = null; let selectedElement = null; let currentSessionId = null; - let pendingAcceptedSession = null; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; + let svelteComponentSession = null; + let svelteRuntimePromise = null; + let pendingSvelteComponentRetryObserver = null; + let currentSourceFile = null; + let currentPreviewFile = null; + let currentPreviewMode = null; + let recoveryWaitingForAnchor = false; + let pendingAcceptedSession = null; let variantObserver = null; + let variantSelectionInFlight = false; + let variantSelectionPromise = null; + let recoveringEmptyCycling = false; let hasProjectContext = false; let selectedAction = 'impeccable'; let selectedCount = 3; @@ -175,14 +204,17 @@ let highlightEl = null; let tooltipEl = null; let barEl = null; + let barHideSeq = 0; let pickerEl = null; let toastEl = null; let scrollRaf = null; let editBadgeEl = null; + let editBadgeProxyRoot = null; + let editBadgeProxyByTarget = new Map(); - // --------------------------------------------------------------------------- + // // Helpers - // --------------------------------------------------------------------------- + // function own(el) { return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); @@ -204,8 +236,105 @@ return s; } + function rectIsUsableAnchor(rect) { + return !!rect && rect.width > 0.5 && rect.height > 0.5; + } + + function makeFrozenAnchor(el) { + if (!el || !el.getBoundingClientRect) return null; + const r = el.getBoundingClientRect(); + if (!rectIsUsableAnchor(r)) return null; + const rect = { + x: r.x, y: r.y, + top: r.top, left: r.left, + right: r.right, bottom: r.bottom, + width: r.width, height: r.height, + }; + return { + __impeccableFrozenAnchor: true, + tagName: el.tagName || 'DIV', + id: el.id || '', + classList: el.classList ? [...el.classList] : [], + hasAttribute: () => false, + getBoundingClientRect: () => rect, + }; + } + function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } + function cssId(id) { + if (window.CSS?.escape) return CSS.escape(id); + return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); + } + + function liveUiRoot() { + const root = window.__IMPECCABLE_LIVE_UI_ROOT__; + if (root && typeof root.appendChild === 'function') return root; + return document.body; + } + + function uiAppend(el) { + liveUiRoot().appendChild(el); + return el; + } + + function uiAppendStyle(styleEl) { + const root = liveUiRoot(); + if (root && root !== document.body) root.appendChild(styleEl); + else document.head.appendChild(styleEl); + return styleEl; + } + + function uiGetById(id) { + const root = liveUiRoot(); + if (root?.getElementById) { + const found = root.getElementById(id); + if (found) return found; + } + if (root?.querySelector) { + const found = root.querySelector('#' + cssId(id)); + if (found) return found; + } + return document.getElementById(id); + } + + function activeElementDeep() { + let active = document.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; + return active; + } + + window.__IMPECCABLE_LIVE_CHROME_CORE__ = { + version: 1, + adapter: window.__IMPECCABLE_LIVE_ADAPTER__ || 'dom', + mountContract: LIVE_CHROME_MOUNT_CONTRACT, + surfaces: LIVE_UI_SURFACES, + componentIds: LIVE_UI_COMPONENT_IDS, + root: liveUiRoot, + append: uiAppend, + appendStyle: uiAppendStyle, + getById: uiGetById, + activeElementDeep, + debugState: () => ({ + state, + currentSessionId, + expectedVariants, + arrivedVariants, + visibleVariant, + savedSession: loadSession(), + sourceFile: currentSourceFile, + previewFile: currentPreviewFile, + previewMode: currentPreviewMode, + barText: barEl?.textContent || null, + barConnected: !!barEl?.isConnected, + hasSvelteComponentSession: !!svelteComponentSession, + mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0, + pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver, + recoveryWaitingForAnchor, + evtSourceReadyState: evtSource ? evtSource.readyState : null, + }), + }; + // Modal-aware chrome: keep our floating UI clickable inside Radix / // Headless UI / vaul portals. // @@ -245,9 +374,9 @@ rootEl.addEventListener('focusin', stop); } - // --------------------------------------------------------------------------- + // // Highlight overlay - // --------------------------------------------------------------------------- + // function initHighlight() { highlightEl = document.createElement('div'); @@ -259,7 +388,7 @@ transition: HIGHLIGHT_TRANSITION, display: 'none', opacity: '0', }); - document.body.appendChild(highlightEl); + uiAppend(highlightEl); tooltipEl = document.createElement('div'); tooltipEl.id = PREFIX + '-tooltip'; @@ -273,7 +402,7 @@ letterSpacing: '0.02em', transition: TOOLTIP_TRANSITION, }); - document.body.appendChild(tooltipEl); + uiAppend(tooltipEl); } function showHighlight(el) { @@ -310,7 +439,7 @@ if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; } } - // --------------------------------------------------------------------------- + // // Annotation overlay (comment pins + kinpaku strokes) // // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned @@ -318,7 +447,7 @@ // drag) drops a comment pin; drag paints a kinpaku SVG stroke. All coords // are stored in element-local CSS px so they survive scroll / resize and // correlate directly with the captured PNG. - // --------------------------------------------------------------------------- + // const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it @@ -397,7 +526,7 @@ annotOverlayEl.addEventListener('pointermove', onAnnotMove); annotOverlayEl.addEventListener('pointerup', onAnnotUp); annotOverlayEl.addEventListener('pointercancel', onAnnotUp); - document.body.appendChild(annotOverlayEl); + uiAppend(annotOverlayEl); // Modal-host friendliness: pointer-events is already 'auto' on this // overlay; we only need to silence the host's outside-interaction // listeners. Don't override pointer-events here (the overlay toggles @@ -828,9 +957,9 @@ return wrap; } - // --------------------------------------------------------------------------- + // // Element context extraction - // --------------------------------------------------------------------------- + // function stripManualEditRuntimeState(root) { if (!root || root.nodeType !== 1) return; @@ -971,9 +1100,9 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } - // --------------------------------------------------------------------------- + // // The Bar - one floating element, three modes - // --------------------------------------------------------------------------- + // // Contextual-bar palette. Cached at init so every build*Row reads a // consistent set of colors; detectPageTheme runs once rather than on every @@ -1006,7 +1135,7 @@ padding: '6px', maxWidth: '520px', minWidth: '320px', }); - document.body.appendChild(barEl); + uiAppend(barEl); defangOutsideHandlers(barEl); } @@ -1041,6 +1170,8 @@ } function showBar(mode) { + barHideSeq += 1; + if (mode === 'cycling' && !ensureCyclingRenderable('show-bar')) return; barEl.innerHTML = ''; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); @@ -1058,11 +1189,12 @@ function hideBar() { if (!barEl) return; + const hideSeq = ++barHideSeq; stopVoice({ suppressSubmit: true }); if (configureKind === 'insert') clearInsertPicking(); barEl.style.opacity = '0'; barEl.style.transform = 'translateY(6px)'; - setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); + setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); if (state === 'EDITING') restoreInlineEditDrafts(); @@ -1071,6 +1203,7 @@ function updateBarContent(mode) { if (!barEl || barEl.style.display === 'none') return; + if (mode === 'cycling' && !ensureCyclingRenderable('update-bar')) return; barEl.innerHTML = ''; // Reset bar styling to the kinpaku picker palette barEl.style.background = BP.surface; @@ -1090,13 +1223,13 @@ syncPageChatFocus('update-bar-content'); } - // --- Configure row --- + // Configure row function syncConfigureInputChrome() { - const wrap = document.getElementById(PREFIX + '-configure-input-wrap'); - const input = document.getElementById(PREFIX + '-input'); + const wrap = uiGetById(PREFIX + '-configure-input-wrap'); + const input = uiGetById(PREFIX + '-input'); if (!wrap || !input) return; - const focused = document.activeElement === input; + const focused = activeElementDeep() === input; wrap.dataset.inputFocused = focused ? 'true' : 'false'; wrap.dataset.voiceListening = (voiceListening && voiceCtx?.mode === 'configure') ? 'true' : 'false'; wrap.style.borderColor = (voiceListening && voiceCtx?.mode === 'configure') @@ -1104,7 +1237,7 @@ : (focused ? BP.accentSoft : BP.hairline); } - // --- Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) --- + // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) function detectInsertAxisFromStyle(style) { const display = style?.display || 'block'; @@ -1377,7 +1510,7 @@ display: 'none', opacity: '0.9', }); - document.body.appendChild(insertLineEl); + uiAppend(insertLineEl); defangOutsideHandlers(insertLineEl); return insertLineEl; } @@ -1440,6 +1573,10 @@ /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { + if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor) return anchor; + } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (wrapper) { @@ -1557,6 +1694,11 @@ positionBar(); } + function showOrUpdateCyclingBar() { + if (barEl && barEl.style.display !== 'none') updateBarContent('cycling'); + else showBar('cycling'); + } + function buildPlaceholderResizeHandles() { if (!placeholderResizeLayerEl) return; placeholderResizeLayerEl.innerHTML = ''; @@ -1665,7 +1807,7 @@ } function isInsertCreateEnabled(btn) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); + btn = btn || uiGetById(PREFIX + '-insert-create'); return !!btn && btn.getAttribute('aria-disabled') !== 'true'; } @@ -1691,7 +1833,7 @@ lineHeight: '1.35', }); insertCreateTooltipEl.id = PREFIX + '-insert-create-tooltip'; - document.body.appendChild(insertCreateTooltipEl); + uiAppend(insertCreateTooltipEl); return insertCreateTooltipEl; } @@ -1723,8 +1865,8 @@ } function syncInsertCreateButton(btn, input) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); - input = input || document.getElementById(PREFIX + '-insert-input'); + btn = btn || uiGetById(PREFIX + '-insert-create'); + input = input || uiGetById(PREFIX + '-insert-input'); if (!btn || !input) return; const gate = insertCreateGateState(input); const ok = canCreateInsert(gate); @@ -1833,7 +1975,7 @@ voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!document.getElementById(PREFIX + '-configure-input-style')) { + if (!uiGetById(PREFIX + '-configure-input-style')) { const s = document.createElement('style'); s.id = PREFIX + '-configure-input-style'; s.textContent = @@ -1842,7 +1984,7 @@ '#' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: impeccable-configure-voice-pulse 1.1s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-configure-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } input.addEventListener('focus', () => syncConfigureInputChrome()); @@ -1949,6 +2091,9 @@ transition: 'border-color 0.15s ease', }); inputWrap.id = PREFIX + '-insert-input-wrap'; + inputWrap.addEventListener('pointerdown', (e) => e.stopPropagation()); + inputWrap.addEventListener('mousedown', (e) => e.stopPropagation()); + inputWrap.addEventListener('click', (e) => e.stopPropagation()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; @@ -1984,6 +2129,12 @@ voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; input.addEventListener('input', () => syncInsertCreateButton()); + input.addEventListener('pointerdown', (e) => e.stopPropagation()); + input.addEventListener('mousedown', (e) => e.stopPropagation()); + input.addEventListener('click', (e) => { + e.stopPropagation(); + try { input.focus({ preventScroll: true }); } catch { input.focus(); } + }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); @@ -2049,6 +2200,7 @@ }); create.addEventListener('mouseleave', hideInsertCreateTooltip); create.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); if (controlsLocked) { showManualApplyBusyToast(); return; } if (!isInsertCreateEnabled(create)) return; @@ -2060,7 +2212,7 @@ return row; } - // --- Generating row --- + // Generating row function buildGeneratingRow() { const row = el('div', { @@ -2086,19 +2238,24 @@ }); // Variants currently arrive atomically in a single file edit, so a // per-variant counter would lie. Say what's true. - status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + expectedVariants + ' variants...' - : 'Done'; + status.textContent = recoveryWaitingForAnchor + ? 'Variants ready. Reveal the selected element to resume.' + : (arrivedVariants < expectedVariants + ? 'Generating ' + expectedVariants + ' variants...' + : 'Done'); row.appendChild(status); return row; } - // --- Cycling row --- + // Cycling row const TUNE_ICON_SVG = ''; function buildCyclingRow() { + if (!ensureCyclingRenderable('build-cycling-row')) { + return el('div', { display: 'none' }); + } const row = el('div', { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 2px', @@ -2106,6 +2263,7 @@ // Prev const prev = navBtn('\u2190'); + prev.id = PREFIX + '-variant-prev'; prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); }); if (visibleVariant <= 1) prev.style.opacity = '0.3'; row.appendChild(prev); @@ -2118,11 +2276,13 @@ fontFamily: MONO, fontSize: '11px', fontWeight: '500', color: BP.textDim, minWidth: '24px', textAlign: 'center', }); + counter.id = PREFIX + '-variant-counter'; counter.textContent = visibleVariant + '/' + arrivedVariants; row.appendChild(counter); // Next const next = navBtn('\u2192'); + next.id = PREFIX + '-variant-next'; next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); }); if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3'; row.appendChild(next); @@ -2208,9 +2368,9 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders - // --- Saving row (waiting for agent to process accept/discard) --- + // Saving row (waiting for agent to process accept/discard) function buildSavingRow() { const row = el('div', { @@ -2235,7 +2395,7 @@ return row; } - // --- Confirmed row (green success, auto-dismisses) --- + // Confirmed row (green success, auto-dismisses) function buildConfirmedRow() { const row = el('div', { @@ -2256,7 +2416,7 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders function buildDots(clickable) { const container = el('div', { @@ -2290,10 +2450,7 @@ const idx = i; dot.addEventListener('click', (e) => { e.stopPropagation(); - visibleVariant = idx; - showVariantInDOM(currentSessionId, idx); - updateSelectedElement(); - updateBarContent('cycling'); + selectVariant(idx, 'variant_changed'); }); } container.appendChild(dot); @@ -2323,13 +2480,14 @@ function el(tag, styles) { const e = document.createElement(tag); + if (String(tag).toLowerCase() === 'button') e.type = 'button'; if (styles) Object.assign(e.style, styles); return e; } - // --------------------------------------------------------------------------- + // // Action picker popover - // --------------------------------------------------------------------------- + // function initActionPicker() { const P = barPaletteForTheme(detectPageTheme()); @@ -2384,16 +2542,20 @@ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent'; }); chip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + const prompt = uiGetById(PREFIX + '-input')?.value || ''; selectedAction = action.value; hideActionPicker(); updateBarContent('configure'); + const input = uiGetById(PREFIX + '-input'); + if (input && prompt) input.value = prompt; }); grid.appendChild(chip); }); pickerEl.appendChild(grid); - document.body.appendChild(pickerEl); + uiAppend(pickerEl); defangOutsideHandlers(pickerEl); // Cache the palette on the picker so toggleActionPicker's state refresh @@ -2433,7 +2595,33 @@ setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180); } - // --------------------------------------------------------------------------- + function ensureCyclingRenderable(reason) { + if (arrivedVariants > 0) { + if (visibleVariant < 1 || visibleVariant > arrivedVariants) visibleVariant = 1; + return true; + } + recoverEmptyCycling(reason); + return false; + } + + function recoverEmptyCycling(reason) { + if (recoveringEmptyCycling) return; + recoveringEmptyCycling = true; + try { + console.warn('[impeccable] Refusing to render empty variant cycling state:', reason); + const message = 'No variants were mounted. Please try again.'; + if (svelteComponentSession?.sessionId === currentSessionId) { + abortSvelteComponentInjection(currentSessionId, message); + return; + } + cleanup(); + showToast(message, 5000); + } finally { + recoveringEmptyCycling = false; + } + } + + // // Params panel (per-variant coarse controls) // // Variants may declare a parameter manifest via a JSON attribute on the @@ -2446,13 +2634,13 @@ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped // CSS can respond instantly without regeneration: // - // range / numeric toggle → CSS var (`--p-`) used via var(--p-foo, N) + // range / numeric toggle -> CSS custom property used by variant styles // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."] // // On variant switch, values reset to that variant's declared defaults. // On accept, current values are sent in the event payload so the agent // can bake them into the source-file write. - // --------------------------------------------------------------------------- + // let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide) let paramsPanelInner = null; // translating content (carries bg, padding, knobs) @@ -2507,7 +2695,7 @@ }); paramsPanelEl.appendChild(paramsPanelBody); - document.body.appendChild(paramsPanelEl); + uiAppend(paramsPanelEl); // Don't override pointer-events: the panel toggles between 'none' (closed, // click-through) and 'auto' (open) on its own. Just silence the host's // outside-interaction listeners while the panel is open. @@ -2516,14 +2704,40 @@ } + function getMountedSvelteComponentAnchor(session = svelteComponentSession) { + const el = session?.mountTargetEl?.firstElementChild || null; + if (!el || !document.body.contains(el)) return null; + return rectIsUsableAnchor(el.getBoundingClientRect()) ? el : null; + } + + function resolveSvelteComponentAnchor(session = svelteComponentSession) { + return getMountedSvelteComponentAnchor(session) + || session?.swapAnchor + || null; + } + function getVisibleVariantEl() { if (!currentSessionId) return null; + if (svelteComponentSession?.sessionId === currentSessionId) { + return resolveSvelteComponentAnchor() + || svelteComponentSession.wrapperEl + || null; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } function parseVariantParams(variantEl) { + // Svelte component variants can't carry a `data-impeccable-params` attribute: + // the compiler reads `{` inside attribute values as expression delimiters, so + // JSON-with-braces breaks the build. For that path the params live in a sidecar + // params.json keyed by variant number, loaded into the session at mount time. + if (svelteComponentSession?.sessionId === currentSessionId) { + const byVariant = svelteComponentSession.paramsByVariant || {}; + const params = byVariant[String(visibleVariant)] || byVariant[visibleVariant]; + return Array.isArray(params) ? params : []; + } if (!variantEl) return []; const raw = variantEl.getAttribute('data-impeccable-params'); if (!raw) return []; @@ -2685,11 +2899,11 @@ } } - // --------------------------------------------------------------------------- + // // Inline text editing - makes pure-text descendants of the picked element // directly contenteditable. Save stages copy edits in the live buffer; the // Apply copy edits dock later asks the AI to apply the staged batch. - // --------------------------------------------------------------------------- + // let inlineEditRows = []; let inlineEditDrafts = new Map(); @@ -2803,7 +3017,7 @@ function disableInlineEdit(opts = {}) { for (const row of inlineEditRows) { - if (document.activeElement === row.el) row.el.blur(); + if (activeElementDeep() === row.el) row.el.blur(); row.el.removeAttribute('contenteditable'); delete row.el.dataset.impeccableEditable; delete row.el.dataset.impeccableOriginalText; @@ -3133,7 +3347,7 @@ if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) { showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); } else { - showToast('Save failed: retry or cancel', 4000); + showToast('Save failed - retry or cancel', 4000); } } } @@ -3181,11 +3395,11 @@ } function ensureSpinKeyframes() { - if (document.getElementById(PREFIX + '-keyframes')) return; + if (uiGetById(PREFIX + '-keyframes')) return; const style = document.createElement('style'); style.id = PREFIX + '-keyframes'; style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }'; - document.head.appendChild(style); + uiAppendStyle(style); } function pendingApplyLabel(count) { @@ -3318,10 +3532,10 @@ closeTunePopover(); } if (barEl && barEl.style.display !== 'none' && state === 'CONFIGURING') { - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value : ''; updateBarContent('configure'); - const nextInput = document.getElementById(PREFIX + '-input'); + const nextInput = uiGetById(PREFIX + '-input'); if (nextInput) nextInput.value = prompt; } if (editBadgeEl && editBadgeEl.style.display !== 'none') { @@ -3455,19 +3669,19 @@ updatePendingCounter(remaining); if (result.failed && result.failed.length > 0) { console.warn('[impeccable] some copy edits failed:', result.failed); - showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed - see console', 5000); } else { const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0); if (n > 0) { showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); } else { console.warn('[impeccable] apply returned no verified edits:', result); - showToast('No edits applied, see console', 4000); + showToast('No edits applied - see console', 4000); } } } catch (err) { console.error('[impeccable] commit failed:', err); - showToast('Apply failed, see console', 4000); + showToast('Apply failed - see console', 4000); } finally { if (waitForSseCompletion) return; const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0; @@ -3497,7 +3711,7 @@ } } catch (err) { console.error('[impeccable] discard failed:', err); - showToast('Discard failed, see console', 4000); + showToast('Discard failed - see console', 4000); } } @@ -3645,7 +3859,7 @@ const failedCount = numberOrNull(msg.failedCount) || 0; const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0; if (failedCount > 0) { - showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000); + showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed - see console', 5000); } else if (appliedCount > 0) { showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500); } @@ -3799,9 +4013,164 @@ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); } - // --------------------------------------------------------------------------- + // // Edit content badge - floating button at element top-right to enter EDITING mode - // --------------------------------------------------------------------------- + // + + function usesShadowChromeRoot() { + const root = liveUiRoot(); + return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; + } + + function setImportantStyle(el, name, value) { + el.style.setProperty(name, value, 'important'); + } + + function initEditBadgeHitProxies() { + if (!usesShadowChromeRoot() || editBadgeProxyRoot) return; + editBadgeProxyRoot = document.createElement('div'); + editBadgeProxyRoot.id = PREFIX + '-edit-badge-hit-proxies'; + editBadgeProxyRoot.setAttribute('aria-hidden', 'true'); + const styles = { + all: 'initial', + position: 'fixed', + inset: '0', + width: '100vw', + height: '100vh', + zIndex: String(Z.toast + 1), + pointerEvents: 'none', + background: 'transparent', + overflow: 'visible', + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + document.body.appendChild(editBadgeProxyRoot); + } + + function styleEditBadgeProxy(proxy, target) { + const rect = target.getBoundingClientRect(); + const cursor = getComputedStyle(target).cursor || 'pointer'; + const styles = { + all: 'initial', + position: 'fixed', + left: rect.left + 'px', + top: rect.top + 'px', + width: rect.width + 'px', + height: rect.height + 'px', + margin: '0', + padding: '0', + border: '0', + borderRadius: '0', + background: 'transparent', + color: 'transparent', + opacity: '0.001', + pointerEvents: 'auto', + cursor, + zIndex: String(Z.toast + 2), + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(proxy, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + } + + function proxyMouseEvent(type, source, target) { + let event; + try { + event = new MouseEvent(type, { + bubbles: type !== 'mouseenter' && type !== 'mouseleave', + cancelable: true, + composed: true, + clientX: source.clientX, + clientY: source.clientY, + screenX: source.screenX, + screenY: source.screenY, + button: source.button || 0, + buttons: source.buttons || 0, + ctrlKey: source.ctrlKey, + metaKey: source.metaKey, + shiftKey: source.shiftKey, + altKey: source.altKey, + }); + target.dispatchEvent(event); + } catch {} + } + + function bindEditBadgeProxy(proxy, target) { + const stop = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + proxy.addEventListener('mouseenter', (event) => { + stop(event); + proxyMouseEvent('mouseenter', event, target); + proxyMouseEvent('mouseover', event, target); + }); + proxy.addEventListener('mouseleave', (event) => { + stop(event); + proxyMouseEvent('mouseleave', event, target); + proxyMouseEvent('mouseout', event, target); + }); + proxy.addEventListener('mousedown', (event) => { + stop(event); + target.focus?.({ preventScroll: true }); + proxyMouseEvent('mousedown', event, target); + }); + proxy.addEventListener('mouseup', (event) => { + stop(event); + proxyMouseEvent('mouseup', event, target); + }); + proxy.addEventListener('click', (event) => { + stop(event); + target.click(); + syncEditBadgeHitProxies(); + }); + } + + function editBadgeProxyTargets() { + if (!usesShadowChromeRoot() || !editBadgeEl || editBadgeEl.style.display === 'none') return []; + return [...editBadgeEl.querySelectorAll('button')].filter((target) => { + if (target.disabled) return false; + const rect = target.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return false; + const style = getComputedStyle(target); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); + } + + function syncEditBadgeHitProxies() { + if (!usesShadowChromeRoot()) { + if (editBadgeProxyRoot) editBadgeProxyRoot.remove(); + editBadgeProxyRoot = null; + editBadgeProxyByTarget = new Map(); + return; + } + initEditBadgeHitProxies(); + if (!editBadgeProxyRoot) return; + const targets = editBadgeProxyTargets(); + const active = new Set(targets); + for (const [target, proxy] of editBadgeProxyByTarget) { + if (!active.has(target) || !target.isConnected) { + proxy.remove(); + editBadgeProxyByTarget.delete(target); + } + } + for (const target of targets) { + let proxy = editBadgeProxyByTarget.get(target); + if (!proxy) { + proxy = document.createElement('button'); + proxy.type = 'button'; + proxy.tabIndex = -1; + proxy.dataset.impeccableEditBadgeProxy = 'true'; + proxy.setAttribute('aria-hidden', 'true'); + bindEditBadgeProxy(proxy, target); + editBadgeProxyRoot.appendChild(proxy); + editBadgeProxyByTarget.set(target, proxy); + } + proxy.title = target.title || target.textContent || 'Edit copy'; + styleEditBadgeProxy(proxy, target); + } + } function initEditBadge() { editBadgeEl = document.createElement('div'); @@ -3813,10 +4182,11 @@ display: 'none', userSelect: 'none', }); - document.body.appendChild(editBadgeEl); + uiAppend(editBadgeEl); + initEditBadgeHitProxies(); // Remove focus rings on edit badge buttons + contenteditable elements - if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + if (!uiGetById(PREFIX + '-edit-badge-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-edit-badge-focus-style'; s.textContent = @@ -3826,21 +4196,26 @@ '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; - document.head.appendChild(s); + uiAppendStyle(s); } } function positionEditBadge() { - if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') { + syncEditBadgeHitProxies(); + return; + } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { if (editBadgeEl) editBadgeEl.style.display = 'none'; + syncEditBadgeHitProxies(); return; } editBadgeEl.style.display = 'flex'; @@ -4047,7 +4422,7 @@ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN; } // Re-render the bar so the Tune chip picks up the active styling. - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } function closeTunePopover() { @@ -4055,13 +4430,13 @@ hideParamsPanel(); if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT; if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') { - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } } - // --------------------------------------------------------------------------- + // // Variant cycling in DOM - // --------------------------------------------------------------------------- + // function isVariantShown(el) { if (!el) return false; @@ -4081,9 +4456,42 @@ } } - function showVariantInDOM(sessionId, num) { + function scheduleCyclingBarSync(sessionId, variantNum) { + requestAnimationFrame(() => { + if (state !== 'CYCLING') return; + if (currentSessionId !== sessionId) return; + if (visibleVariant !== variantNum) return; + showOrUpdateCyclingBar(); + syncCyclingControls(); + positionBar(); + }); + } + + function syncCyclingControls() { + const shown = svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0 + ? svelteComponentSession.mountedVariant + : visibleVariant; + const counter = uiGetById(PREFIX + '-variant-counter'); + if (counter && arrivedVariants > 0) counter.textContent = shown + '/' + arrivedVariants; + const prev = uiGetById(PREFIX + '-variant-prev'); + const next = uiGetById(PREFIX + '-variant-next'); + if (prev) prev.style.opacity = shown <= 1 ? '0.3' : '1'; + if (next) next.style.opacity = shown >= arrivedVariants ? '0.3' : '1'; + if (currentSessionId && state === 'CYCLING') saveSession(); + } + + async function showVariantInDOM(sessionId, num) { + if (svelteComponentSession?.sessionId === sessionId) { + visibleVariant = num; + const mounted = await mountSvelteComponentVariant(num); + if (!mounted) return false; + updateSelectedElement(); + refreshParamsPanel(); + scheduleCyclingBarSync(sessionId, num); + return true; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); - if (!wrapper) return; + if (!wrapper) return false; for (const child of wrapper.children) { const v = child.dataset ? child.dataset.impeccableVariant : null; if (!v) continue; @@ -4093,6 +4501,378 @@ // CYCLING yet, the subsequent CYCLING transition triggers its own // refresh) and every cycle step. refreshParamsPanel(); + return true; + } + + function isSvelteComponentManifestPath(filePath) { + return String(filePath || '').endsWith('manifest.json'); + } + + function parseOriginalMarkupElement(originalMarkup) { + const parser = new DOMParser(); + const doc = parser.parseFromString('
' + originalMarkup + '
', 'text/html'); + return doc.getElementById('impeccable-anchor')?.firstElementChild || null; + } + + function findLiveElementForOriginalMarkup(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const tag = origContent.tagName.toLowerCase(); + const cls = origContent.className; + let liveEl = null; + if (origContent.id) { + liveEl = document.getElementById(origContent.id); + } else if (cls) { + const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + for (const c of candidates) { + if (c.className === cls && !own(c)) { liveEl = c; break; } + } + if (!liveEl) { + const expectedClasses = String(cls).split(/\s+/).filter(Boolean); + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } + } + } + return liveEl; + } + + function isSvelteInsertManifest(manifest) { + return manifest?.previewMode === 'svelte-component' && manifest?.mode === 'insert'; + } + + function findLiveElementForSvelteManifest(manifest) { + if (isSvelteInsertManifest(manifest)) { + const anchor = findInsertAnchorInDom(); + if (anchor?.parentElement) return anchor; + } + return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function loadSvelteRuntime(runtimeModule) { + const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js'; + const url = new URL(modulePath, location.origin).href; + if (!svelteRuntimePromise) { + svelteRuntimePromise = import(/* @vite-ignore */ url); + } + return svelteRuntimePromise; + } + + // Svelte component variants declare their params in a sidecar params.json under + // componentDir (keyed by variant number), because a `data-impeccable-params` + // attribute with JSON braces can't survive the Svelte compiler. Returns a map of + // { "1": [...params], "2": [...] }; an empty object when the agent declared none. + async function loadSvelteComponentParams(manifest) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir) return {}; + const paramsPath = dir + '/params.json'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(paramsPath); + try { + const res = await fetch(url); + if (!res.ok) return {}; + const parsed = JSON.parse(await res.text()); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out = {}; + for (const [key, value] of Object.entries(parsed)) { + if (Array.isArray(value)) out[String(key)] = value; + } + return out; + } catch { + return {}; + } + } + + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { + const contract = manifest?.propContract || []; + const values = {}; + if (!liveEl || contract.length === 0) return values; + const sourceOriginal = parseOriginalMarkupElement(manifest.originalMarkup || ''); + if (!sourceOriginal) return values; + const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl); + for (const entry of contract) { + const token = '{' + entry.expr + '}'; + values[entry.prop] = map.get(token) || ''; + } + return values; + } + + async function mountSvelteComponentVariant(variantNum) { + if (!svelteComponentSession || !variantNum) return false; + const { manifest, mountTargetEl, sessionId } = svelteComponentSession; + try { + const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement; + svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null; + const runtime = await loadSvelteRuntime(manifest.runtimeModule); + const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte'; + const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now(); + const mod = await import(/* @vite-ignore */ moduleUrl); + const Component = mod.default; + if (svelteComponentSession.mountedInstance && runtime.unmount) { + await runtime.unmount(svelteComponentSession.mountedInstance); + svelteComponentSession.mountedInstance = null; + } + svelteComponentSession.mountedInstance = runtime.mount(Component, { + target: mountTargetEl, + props: { ...svelteComponentSession.propValues }, + intro: false, + }); + svelteComponentSession.mountedVariant = variantNum; + svelteComponentSession.runtime = runtime; + if (state === 'CYCLING') syncCyclingControls(); + const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (nextAnchor) { + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(nextAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = nextAnchor; + } else { + requestAnimationFrame(() => { + if (svelteComponentSession?.sessionId !== sessionId) return; + const settledAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!settledAnchor) return; + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(settledAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = settledAnchor; + }); + } + return true; + } catch (err) { + if (svelteComponentSession?.sessionId === sessionId) { + svelteComponentSession.swapAnchor = null; + } + console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err); + return false; + } + } + + function teardownSvelteComponentSession(restoreOriginal) { + if (!svelteComponentSession) return; + const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + if (restoreOriginal && detachedOriginal && wrapperEl?.parentElement) { + wrapperEl.parentElement.replaceChild(detachedOriginal, wrapperEl); + } else if (wrapperEl?.parentElement) { + wrapperEl.remove(); + } + svelteComponentSession = null; + svelteRuntimePromise = null; + } + + function applyOriginalAttrsToSvelteAnchor(el, originalMarkup) { + if (!el || !originalMarkup) return; + const original = parseOriginalMarkupElement(originalMarkup); + if (!original || original.tagName !== el.tagName) return; + for (const attr of original.attributes) { + if (attr.name === 'class') { + for (const className of attr.value.split(/\s+/).filter(Boolean)) { + el.classList.add(className); + } + } else if (!el.hasAttribute(attr.name)) { + el.setAttribute(attr.name, attr.value); + } + } + } + + function commitAcceptedSvelteComponentToDom(sessionId) { + if (!svelteComponentSession || svelteComponentSession.sessionId !== sessionId) return false; + const { wrapperEl, runtime, mountedInstance, manifest } = svelteComponentSession; + const anchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!anchor || !wrapperEl?.parentElement) return false; + const committed = anchor.cloneNode(true); + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(committed, manifest.originalMarkup || ''); + } + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + wrapperEl.parentElement.replaceChild(committed, wrapperEl); + svelteComponentSession = null; + svelteRuntimePromise = null; + selectedElement = committed; + return true; + } + + async function injectSvelteComponentsFromManifest(manifestPath, sessionId) { + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(manifestPath); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + const manifest = JSON.parse(await res.text()); + if (manifest.id !== sessionId) return; + + const paramsByVariant = await loadSvelteComponentParams(manifest); + currentSessionId = sessionId; + expectedVariants = Number(manifest.count) || expectedVariants || 1; + rememberSessionFileMeta({ + sourceFile: manifest.sourceFile, + previewFile: manifestPath, + previewMode: 'svelte-component', + }); + if (state !== 'CYCLING') state = 'GENERATING'; + + const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { + recoveryWaitingForAnchor = false; + svelteComponentSession.paramsByVariant = paramsByVariant; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; + await mountSvelteComponentVariant(visibleVariant || 1); + state = 'CYCLING'; + showOrUpdateCyclingBar(); + saveSession(); + return; + } + + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) { + console.warn('[impeccable] Could not find original element in live DOM.'); + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants + ? visibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + saveSession(); + queueCheckpoint('svelte_component_anchor_missing'); + waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return; + } + + const wrapper = document.createElement('div'); + wrapper.dataset.impeccableVariants = sessionId; + wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1); + wrapper.dataset.impeccablePreview = 'svelte-component'; + wrapper.style.display = 'contents'; + + const mountTarget = document.createElement('div'); + mountTarget.dataset.impeccableComponentMount = sessionId; + mountTarget.style.display = 'contents'; + wrapper.appendChild(mountTarget); + + const insertMode = isSvelteInsertManifest(manifest); + const detachedOriginal = insertMode ? null : liveEl; + if (insertMode) { + removeInsertPlaceholderDom(); + if (manifest.position === 'before') liveEl.parentElement.insertBefore(wrapper, liveEl); + else liveEl.parentElement.insertBefore(wrapper, liveEl.nextSibling); + } else { + liveEl.parentElement.replaceChild(wrapper, liveEl); + } + + svelteComponentSession = { + sessionId, + manifest, + insertMode, + wrapperEl: wrapper, + mountTargetEl: mountTarget, + detachedOriginal, + mountedInstance: null, + mountedVariant: 0, + runtime: null, + propValues: buildSveltePropValuesFromLiveElement(detachedOriginal, manifest), + paramsByVariant, + }; + if (pendingSvelteComponentRetryObserver) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + } + recoveryWaitingForAnchor = false; + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + + const mounted = await mountSvelteComponentVariant(visibleVariant); + if (!mounted) { + // The compiled component threw (e.g. a Svelte compile error in the + // variant file). Don't strand the bar in an empty CYCLING state; restore + // the original element and reset to PICKING so the user can retry. + abortSvelteComponentInjection(sessionId, 'A variant failed to compile. Fix the component and re-run.'); + return; + } + + selectedElement = mountTarget.firstElementChild || mountTarget; + state = 'CYCLING'; + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.'); + } catch (err) { + console.error('[impeccable] Failed to mount Svelte component variants:', err); + abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.'); + } + } + + function waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }) { + if (pendingSvelteComponentRetryObserver) pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = new MutationObserver(() => { + if (svelteComponentSession?.sessionId === sessionId) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + return; + } + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) return; + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + injectSvelteComponentsFromManifest(manifestPath, sessionId); + }); + pendingSvelteComponentRetryObserver.observe(document.body, { childList: true, subtree: true }); + } + + // Reset cleanly when a Svelte component session can't mount: tear the wrapper + // down (restoring the original element), clear persisted session state, and + // return the bar to PICKING. Avoids the stuck 0/0 CYCLING bar. + function abortSvelteComponentInjection(sessionId, message) { + try { + if (svelteComponentSession?.sessionId === sessionId) { + teardownSvelteComponentSession(true); + } else { + const orphan = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (orphan) orphan.remove(); + } + } catch (err) { + console.warn('[impeccable] Svelte component abort cleanup failed:', err); + } + hideShaderOverlay(); + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + stopScrollLock(); + clearSession(); + clearHandled(); + resetSessionFileMeta(); + currentSessionId = null; + expectedVariants = 0; + arrivedVariants = 0; + visibleVariant = 0; + selectedElement = null; + state = 'PICKING'; + hideBar(); + if (message) showToast(message, 5000); } /** @@ -4101,6 +4881,11 @@ * This works even when the dev server caches HTML (Bun, static servers). */ function injectVariantsFromSource(filePath, sessionId) { + if (isSvelteComponentManifestPath(filePath)) { + injectSvelteComponentsFromManifest(filePath, sessionId); + return; + } + rememberSessionFileMeta({ file: filePath }); const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) @@ -4119,7 +4904,7 @@ const doc = parser.parseFromString(block, 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { - console.error('[impeccable] Variant wrapper not found in source file.'); + console.warn('[impeccable] Variant wrapper not found in source file.'); return; } @@ -4134,31 +4919,31 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; - if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - } - + const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); if (!liveEl) { - console.error('[impeccable] Could not find original element in live DOM.'); + console.warn('[impeccable] Could not find original element in live DOM.'); + selectedElement = document.body; + recoveryWaitingForAnchor = true; + state = 'GENERATING'; + showBar('generating'); + saveSession(); + showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } + recoveryWaitingForAnchor = false; // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); arrivedVariants = variants.length; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + recoverEmptyCycling('source-fallback-empty'); + return; + } const saved = loadSession(); const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants @@ -4170,8 +4955,9 @@ selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4184,21 +4970,129 @@ }); } - function cycleVariant(dir) { + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { + const map = new Map(); + if (!sourceOriginal || !liveOriginal) return map; + + const sourceNodes = collectTextNodes(sourceOriginal) + .filter((node) => /\{[^{}]+\}/.test(node.nodeValue || '')); + const liveTexts = collectTextNodes(liveOriginal) + .map((node) => normalizePreviewText(node.nodeValue || '')) + .filter(Boolean); + let liveIndex = 0; + + for (const sourceNode of sourceNodes) { + const sourceText = sourceNode.nodeValue || ''; + const tokens = sourceText.match(/\{[^{}]+\}/g) || []; + if (tokens.length === 0) continue; + + const liveText = liveTexts[liveIndex++] || ''; + if (!liveText) continue; + + if (tokens.length === 1) { + const token = tokens[0]; + const normalizedSource = normalizePreviewText(sourceText); + if (normalizedSource === token) { + map.set(token, liveText); + continue; + } + + const match = liveText.match(expressionTextMatcher(sourceText, [token])); + if (match && match[1]) map.set(token, match[1].trim()); + continue; + } + + if (normalizePreviewText(sourceText) === tokens.join(' ')) { + for (const token of tokens) { + const tokenLiveText = liveTexts[liveIndex - 1] || ''; + if (tokenLiveText) map.set(token, tokenLiveText); + } + } + } + + return map; + } + + function expressionTextMatcher(sourceText, tokens) { + let pattern = '^'; + let cursor = 0; + for (const token of tokens) { + const index = sourceText.indexOf(token, cursor); + if (index === -1) continue; + pattern += escapeRegExp(sourceText.slice(cursor, index)).replace(/\s+/g, '\\s*'); + pattern += '(.*?)'; + cursor = index + token.length; + } + pattern += escapeRegExp(sourceText.slice(cursor)).replace(/\s+/g, '\\s*') + '$'; + return new RegExp(pattern); + } + + function collectTextNodes(root) { + if (!root) return []; + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + nodes.push(node); + node = walker.nextNode(); + } + return nodes; + } + + function normalizePreviewText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + async function selectVariant(next, checkpointReason) { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - const next = visibleVariant + dir; + if (variantSelectionInFlight) return; if (next < 1 || next > arrivedVariants) return; - visibleVariant = next; - showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself - updateSelectedElement(); - updateBarContent('cycling'); - positionBar(); - saveSession(); - queueCheckpoint('variant_changed'); + if (next === visibleVariant) return; + + const previous = visibleVariant; + variantSelectionInFlight = true; + const selectionPromise = (async () => { + visibleVariant = next; + showOrUpdateCyclingBar(); + saveSession(); + const shown = await showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself + if (!shown) { + visibleVariant = previous; + await showVariantInDOM(currentSessionId, previous); + showOrUpdateCyclingBar(); + saveSession(); + return; + } + updateSelectedElement(); + showOrUpdateCyclingBar(); + positionBar(); + saveSession(); + if (checkpointReason) queueCheckpoint(checkpointReason); + })(); + variantSelectionPromise = selectionPromise; + try { + await selectionPromise; + } finally { + if (variantSelectionPromise === selectionPromise) variantSelectionPromise = null; + variantSelectionInFlight = false; + } + } + + function cycleVariant(dir) { + selectVariant(visibleVariant + dir, 'variant_changed'); } function updateSelectedElement() { if (!currentSessionId) return; + if (svelteComponentSession?.sessionId === currentSessionId) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; + return; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); @@ -4206,6 +5100,9 @@ } function readVisibleVariantFromDOM(sessionId) { + if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { + return svelteComponentSession.mountedVariant; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -4341,9 +5238,9 @@ // scrollY that the next resume needs to read. } - // --------------------------------------------------------------------------- + // // MutationObserver for progressive variant reveal - // --------------------------------------------------------------------------- + // function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -4426,10 +5323,11 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); updateSelectedElement(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4445,9 +5343,9 @@ return obs; } - // --------------------------------------------------------------------------- + // // Bar scroll tracking - // --------------------------------------------------------------------------- + // function startScrollTracking() { function tick() { @@ -4483,10 +5381,10 @@ if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; } } - // --------------------------------------------------------------------------- + // // SSE (server→browser) + fetch POST (browser→server) // Zero-dependency replacement for WebSocket. - // --------------------------------------------------------------------------- + // let evtSource = null; let sseRetries = 0; @@ -4509,6 +5407,7 @@ console.log('[impeccable] Live mode connected.'); syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); + restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; syncPageChatFocus('sse-connected'); break; @@ -4531,11 +5430,12 @@ break; case 'done': if (maybeCompleteSteer(msg)) break; + rememberSessionFileMeta(msg); // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); } @@ -4557,7 +5457,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; if (state !== 'GENERATING') return; showToast( - "Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.", + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", 15000, ); }, 2000); @@ -4571,12 +5471,18 @@ // the final complete event. Keep the browser in its recoverable // saving state while the source cleanup is still in flight. break; + case 'discarded': + if (msg.id && msg.id === currentSessionId) { + markSessionHandled(); + cleanup(); + } + break; case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; state = 'CYCLING'; updateBarContent('cycling'); - showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000); + showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; } if (maybeCompleteSteer(msg)) break; @@ -4656,6 +5562,9 @@ expectedVariants, arrivedVariants, visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, paramValues: { ...paramsCurrentValues }, }; } @@ -4665,6 +5574,20 @@ return sendEvent(checkpointPayload(reason)).catch(() => null); } + function sendSteerCheckpoint(id, reason, extra) { + if (!id) return Promise.resolve(null); + return sendEvent({ + type: 'checkpoint', + id, + revision: sessionState.nextCheckpointRevision(), + owner: browserOwner, + phase: 'steer', + reason, + pageUrl: location.pathname, + ...(extra || {}), + }).catch(() => null); + } + function queueCheckpoint(reason) { if (!currentSessionId) return; if (checkpointTimer) clearTimeout(checkpointTimer); @@ -4674,9 +5597,9 @@ }, 120); } - // --------------------------------------------------------------------------- + // // Event handlers - // --------------------------------------------------------------------------- + // function handleMouseMove(e) { if (pendingApplyInFlight) return; @@ -4860,7 +5783,7 @@ // // DISABLED: quick-Go workflows pay an extra harness round trip because // prefetch + generate arrive as two events instead of one. Re-enable with - // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to + // a browser-side debounce (~800-1000ms, cancelled on Go) if we want to // resurrect this. Server validator and skill dispatch remain in place so // flipping this flag is the only change needed. const PREFETCH_ENABLED = false; @@ -4876,6 +5799,14 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + const deepActive = activeElementDeep(); + if ( + deepActive + && own(deepActive) + && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + ) { + return; + } // While a contenteditable text-leaf is focused, let the browser handle // all keys except Escape. Escape cancels the current edit (restores // original text) and blurs without saving, staying in CONFIGURING. @@ -4982,7 +5913,7 @@ if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!selectedElement || state !== 'CONFIGURING') return; stopVoice({ suppressSubmit: true }); - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; // Commit any pending pin edit BEFORE we snapshot annotations. @@ -4996,6 +5927,7 @@ expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); // Flip to GENERATING immediately so the bar morphs without waiting on // capture + upload. The event is emitted from captureAndEmit() once the @@ -5054,7 +5986,7 @@ function handleInsertCreate() { if (!placeholderElement || !insertAnchorElement || state !== 'CONFIGURING' || configureKind !== 'insert') return; - const input = document.getElementById(PREFIX + '-insert-input'); + const input = uiGetById(PREFIX + '-insert-input'); const prompt = input ? input.value.trim() : ''; if (annotEditing) finalizeEditingPin(); const snapshot = { @@ -5064,10 +5996,12 @@ if (!canCreateInsert({ prompt, comments: snapshot.comments, strokes: snapshot.strokes })) return; stopVoice({ suppressSubmit: true }); + pendingAcceptedSession = null; currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); selectedElement = placeholderElement; insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement); @@ -5107,9 +6041,9 @@ captureAndEmit(elForCapture, basePayload, snapshot, captureRect); } - // --------------------------------------------------------------------------- + // // Screenshot capture + upload - // --------------------------------------------------------------------------- + // let msLoadPromise = null; function loadModernScreenshot() { @@ -5120,7 +6054,7 @@ s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; s.onload = () => resolve(window.modernScreenshot); s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; - document.head.appendChild(s); + uiAppendStyle(s); }); return msLoadPromise; } @@ -5235,11 +6169,113 @@ return '#ffffff'; } + function captureChromeNodes() { + const nodes = []; + const add = (node) => { + if (!node || node === document.body || nodes.includes(node)) return; + nodes.push(node); + }; + add(document.getElementById(PREFIX + '-root')); + [ + PREFIX + '-highlight', + PREFIX + '-tooltip', + PREFIX + '-bar', + PREFIX + '-picker', + PREFIX + '-params-panel', + PREFIX + '-insert-line', + PREFIX + '-insert-placeholder', + PREFIX + '-insert-create-tooltip', + PREFIX + '-annot', + PREFIX + '-design-host', + PREFIX + '-toast', + PREFIX + '-shader', + ].forEach((id) => add(uiGetById(id))); + return nodes; + } + + async function hideCaptureChromeForShaderProxy(fn) { + const saved = captureChromeNodes().map((node) => ({ + node, + visibility: node.style.visibility, + priority: node.style.getPropertyPriority('visibility'), + })); + for (const { node } of saved) { + node.style.setProperty('visibility', 'hidden', 'important'); + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + try { + return await fn(); + } finally { + for (const { node, visibility, priority } of saved) { + node.style.setProperty('visibility', visibility, priority); + } + } + } + + function shouldUseAncestorCropShaderProxy(el) { + // TODO: Enable this proxy for React/Vue/etc. adapters once their live + // preview mounts are covered by the same shader regression checks. + const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase(); + if (adapter === 'svelte' || adapter === 'sveltekit') return true; + if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true; + const wrapper = el?.closest?.('[data-impeccable-variants]'); + return wrapper?.dataset?.impeccablePreview === 'svelte-component'; + } + + function paintsShaderProxySurface(node) { + const s = getComputedStyle(node); + return !isTransparentColor(s.backgroundColor) + || (s.backgroundImage && s.backgroundImage !== 'none') + || paintsBackdrop(node); + } + + function findShaderProxyCaptureRoot(el) { + const doc = el.ownerDocument || document; + const er = el.getBoundingClientRect(); + let node = el.parentElement; + while (node && node !== doc.documentElement) { + const nr = node.getBoundingClientRect(); + const containsElement = + nr.width > 0 && nr.height > 0 && + nr.left <= er.left + 0.5 && + nr.top <= er.top + 0.5 && + nr.right >= er.right - 0.5 && + nr.bottom >= er.bottom - 0.5; + if (containsElement && paintsShaderProxySurface(node)) return node; + node = node.parentElement; + } + return null; + } + // Capture the element (with current annotations baked in) and return // { blob, paper }: the PNG Blob, plus the representative backdrop tone for the // shader's halftone ground (so capture, upload, and shader all agree on what // sits behind the element). Shared between the Go flow (uploads the blob) and // the shader-resume path. + async function captureElementFromRenderedAncestor(ms, el, opts) { + const doc = el.ownerDocument || document; + const captureRoot = findShaderProxyCaptureRoot(el); + if (!captureRoot) throw new Error('No painted ancestor for Svelte shader proxy'); + const rootCanvas = await ms.domToCanvas(captureRoot, opts); + const S = opts.scale; + const er = el.getBoundingClientRect(); + const rr = captureRoot.getBoundingClientRect(); + const sx = (er.left - rr.left) * S; + const sy = (er.top - rr.top) * S; + const sw = er.width * S; + const sh = er.height * S; + if (sw <= 0 || sh <= 0) throw new Error('Selected element has no visible capture rect'); + const crop = doc.createElement('canvas'); + crop.width = Math.max(1, Math.round(sw)); + crop.height = Math.max(1, Math.round(sh)); + const cctx = crop.getContext('2d', { willReadFrequently: true }); + cctx.drawImage(rootCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height); + const paper = dominantRgb01(cctx, crop.width, crop.height) || averageRgb01(cctx, crop.width, crop.height); + const blob = await new Promise((res) => crop.toBlob(res, 'image/png')); + if (!blob) throw new Error('Ancestor crop failed to produce a PNG blob'); + return { blob, paper }; + } + async function captureElementToBlob(el, snapshot, rect) { try { if (document.fonts?.ready) await document.fonts.ready; } catch {} const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); @@ -5261,6 +6297,13 @@ scale: Math.min(window.devicePixelRatio || 1, 2), font: fontCssText ? { cssText: fontCssText } : undefined, }; + if (shouldUseAncestorCropShaderProxy(el)) { + try { + return await hideCaptureChromeForShaderProxy(() => captureElementFromRenderedAncestor(ms, el, opts)); + } catch (err) { + console.warn('[impeccable] Svelte ancestor crop capture failed, falling back to element capture:', err); + } + } const bg = resolveCanvasBackground(el); // Fast path: the element paints its own background, or an opaque ancestor // color was found. modern-screenshot bakes that color; paper matches it. @@ -5342,13 +6385,13 @@ sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); } - // --------------------------------------------------------------------------- + // // Shader overlay - renders the captured screenshot as a WebGL texture and // runs an editorial "ink-wash" fragment shader over it during generation. // A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku // and leaving a soft trail. Makes the wait feel like a letterpress scan // instead of a dead spinner. - // --------------------------------------------------------------------------- + // const SHADER_VS = `attribute vec2 a_position; attribute vec2 a_uv; @@ -5502,6 +6545,31 @@ void main() { return n ? [r / n / 255, g / n / 255, b / n / 255] : SHADER_PAPER_FALLBACK; } + // Pick the most common visible color cluster from a crop. A straight average + // gets pulled by text and icons; the dominant bucket usually represents the + // surface the shader should dissolve into. + function dominantRgb01(ctx, w, h) { + const data = ctx.getImageData(0, 0, w, h).data; + const stride = Math.max(1, Math.floor((w * h) / 6000)); + const buckets = new Map(); + for (let p = 0; p < w * h; p += stride) { + const i = p * 4; + if (data[i + 3] < 16) continue; + const key = (data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4); + const bucket = buckets.get(key) || { count: 0, r: 0, g: 0, b: 0 }; + bucket.count += 1; + bucket.r += data[i]; + bucket.g += data[i + 1]; + bucket.b += data[i + 2]; + buckets.set(key, bucket); + } + let best = null; + for (const bucket of buckets.values()) { + if (!best || bucket.count > best.count) best = bucket; + } + return best ? [best.r / best.count / 255, best.g / best.count / 255, best.b / best.count / 255] : null; + } + // Average the backdrop sampled just OUTSIDE an element's rect within a larger // canvas. The ground tone for the dissolve must be the real backdrop, not the // mean of the element's own crop - averaging the crop folds in the element's @@ -5572,7 +6640,7 @@ void main() { fallback.style.backgroundRepeat = 'no-repeat'; fallback.style.outline = '2px dashed ' + C.brand; fallback.style.outlineOffset = '-2px'; - document.body.appendChild(fallback); + uiAppend(fallback); shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl }; } @@ -5582,16 +6650,19 @@ void main() { const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); + const radius = getComputedStyle(el).borderRadius; canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); Object.assign(canvas.style, { position: 'fixed', top: rect.top + 'px', left: rect.left + 'px', width: rect.width + 'px', height: rect.height + 'px', + borderRadius: radius, + overflow: 'hidden', pointerEvents: 'none', zIndex: Z.bar - 1, }); - document.body.appendChild(canvas); + uiAppend(canvas); const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) || canvas.getContext('experimental-webgl'); @@ -5685,8 +6756,12 @@ void main() { frame(); } - function handleAccept() { + async function handleAccept() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } + if (pendingAcceptedSession || state === 'SAVING') return; + if (variantSelectionPromise) { + try { await variantSelectionPromise; } catch { /* failed selection falls back below */ } + } if (!currentSessionId || arrivedVariants === 0) return; const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId); if (domVisibleVariant > 0) visibleVariant = domVisibleVariant; @@ -5696,30 +6771,39 @@ void main() { variantId: String(visibleVariant), pageUrl: location.pathname, }; + const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } + // The accepted variant is already the only visible child of the wrapper + // (all other variants are display:none). HMR from the source rewrite will + // replace the wrapper imminently. Don't eagerly replaceChild here - React + // reconciliation races with our mutation and throws NotFoundError in Next + // 16 / Turbopack. Schedule a fallback that runs the manual swap only if + // HMR hasn't cleaned up by then (keeps static-server flows working). const acceptedSessionId = currentSessionId; const acceptedVariant = visibleVariant; + const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId + || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - pendingAcceptedSession = { - id: acceptedSessionId, - variant: String(acceptedVariant), - ...acceptedSnapshot, - finalizing: false, - }; state = 'SAVING'; updateBarContent('saving'); + pendingAcceptedSession = { + id: acceptedSessionId, + variant: String(acceptedVariant), + isSvelteComponent: acceptedIsSvelteComponent, + ...acceptedSnapshot, + finalizing: false, + }; + saveSession(); sendEvent(acceptPayload, { throwOnError: true }) - .then(() => { - markSessionHandled(); - }) + .then(() => {}) .catch(() => { - pendingAcceptedSession = null; + if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); } @@ -5733,19 +6817,21 @@ void main() { } if (pending.finalizing) return true; pending.finalizing = true; - + markSessionHandled(); + if (pending.isSvelteComponent) { + commitAcceptedSvelteComponentToDom(pending.id); + } state = 'CONFIRMED'; updateBarContent('confirmed'); + scheduleAcceptCleanup(pending); + return true; + } - // Give framework HMR a short chance to render the now-clean accepted - // source. If it misses the update, unwrap the accepted variant after the - // source-side completion event so the page is not left empty or stale. + function scheduleAcceptCleanup(accepted) { setTimeout(function() { - ensureAcceptedDomClean(pending); + if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted); cleanupAcceptedSession(); }, 1200); - - return true; } function snapshotAcceptedVariantDom(sessionId, variantId) { @@ -5833,6 +6919,7 @@ void main() { stopScrollLock(); clearScrollY(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5841,6 +6928,28 @@ void main() { state = 'PICKING'; } + function commitAcceptedVariantToDom(sessionId, variantId) { + const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapper) return false; + const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); + if (!accepted || !accepted.firstElementChild) return false; + const parent = wrapper.parentElement; + if (!parent) return false; + + const style = wrapper.querySelector('style[data-impeccable-css]'); + if (style && !document.querySelector('style[data-impeccable-accepted-css="' + sessionId + '"]')) { + const promotedStyle = style.cloneNode(true); + promotedStyle.setAttribute('data-impeccable-accepted-css', sessionId); + parent.insertBefore(promotedStyle, wrapper); + } + + const committed = accepted.cloneNode(true); + committed.removeAttribute('hidden'); + committed.style.display = 'contents'; + parent.replaceChild(committed, wrapper); + return true; + } + function handleDiscard() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!currentSessionId) return; @@ -5852,11 +6961,141 @@ void main() { .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000)); } - // --------------------------------------------------------------------------- + // // Session persistence via live-browser-session.js - // --------------------------------------------------------------------------- + // // Survives page reloads, browser close/reopen, HMR, and accidental refreshes. + function normalizeSessionPath(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed.replace(/\\/g, '/') : null; + } + + function resetSessionFileMeta() { + currentSourceFile = null; + currentPreviewFile = null; + currentPreviewMode = null; + recoveryWaitingForAnchor = false; + } + + function rememberSessionFileMeta(meta = {}) { + const file = normalizeSessionPath(meta.file); + const sourceFile = normalizeSessionPath(meta.sourceFile); + const previewFile = normalizeSessionPath(meta.previewFile); + const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null); + + if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) { + currentPreviewMode = 'svelte-component'; + currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile); + currentSourceFile = sourceFile || currentSourceFile; + return; + } + + if (sourceFile || file) currentSourceFile = sourceFile || file; + if (previewFile) currentPreviewFile = previewFile; + if (previewMode) currentPreviewMode = previewMode; + } + + function applySavedSessionMeta(saved) { + if (!saved) return; + rememberSessionFileMeta(saved); + if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.action) selectedAction = saved.action; + if (saved.count) selectedCount = saved.count; + if (saved.previewMode) currentPreviewMode = saved.previewMode; + if (saved.paramValues && typeof saved.paramValues === 'object') { + paramsCurrentValues = { ...saved.paramValues }; + } + } + + function normalizePagePath(value) { + if (!value || typeof value !== 'string') return null; + try { + return new URL(value, location.origin).pathname; + } catch { + return value.split(/[?#]/)[0] || null; + } + } + + function pageMatchesCurrent(value) { + const path = normalizePagePath(value); + return !path || path === location.pathname; + } + + function isTerminalSessionSummary(session) { + return /^(completed|discarded|discard_requested|accept_requested)$/.test(String(session?.phase || '')); + } + + function findActiveSessionSummary(saved, activeSessions) { + if (!saved?.id || !Array.isArray(activeSessions)) return null; + return activeSessions.find((session) => + session?.id === saved.id + && pageMatchesCurrent(session.pageUrl || saved.pageUrl) + && !isTerminalSessionSummary(session) + ) || null; + } + + function clampVariantIndex(value, count) { + const num = Number(value); + const max = Number(count); + if (!Number.isFinite(num) || num < 1) return 0; + if (Number.isFinite(max) && max > 0 && num > max) return 0; + return Math.floor(num); + } + + function restoreSessionWithoutWrapper(reason, activeSessions) { + const saved = loadSession(); + if (!saved?.id || isSessionHandled(saved.id)) return false; + const savedState = String(saved.state || '').toUpperCase(); + if (savedState !== 'GENERATING' && savedState !== 'CYCLING') return false; + + const serverSession = findActiveSessionSummary(saved, activeSessions); + if (Array.isArray(activeSessions) && activeSessions.length > 0 && !serverSession) { + return false; + } + + currentSessionId = saved.id; + applySavedSessionMeta(serverSession); + applySavedSessionMeta(saved); + + expectedVariants = Number(saved.expected || serverSession?.expectedVariants || selectedCount || 0); + arrivedVariants = Number(saved.arrived || serverSession?.arrivedVariants || 0); + if (arrivedVariants <= 0 && currentPreviewFile) arrivedVariants = Number(serverSession?.expectedVariants || saved.expected || selectedCount || 0); + if (expectedVariants <= 0) expectedVariants = Number(serverSession?.expectedVariants || arrivedVariants || selectedCount || 0); + visibleVariant = clampVariantIndex(saved.visible, arrivedVariants || expectedVariants) + || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) + || (arrivedVariants > 0 ? 1 : 0); + + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + if (variantObserver) variantObserver.disconnect(); + variantObserver = startVariantObserver(currentSessionId); + saveSession(); + queueCheckpoint(reason || 'browser_restore_without_wrapper'); + + const restoreFile = currentPreviewMode === 'svelte-component' + ? currentPreviewFile + : (currentSourceFile || currentPreviewFile); + if (restoreFile) { + injectVariantsFromSource(restoreFile, currentSessionId); + return true; + } + + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return true; + } + + function restoreFromActiveSessions(activeSessions, reason) { + const wrapper = document.querySelector('[data-impeccable-variants]'); + if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false; + if (svelteComponentSession?.sessionId === currentSessionId) return false; + return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); + } + function saveSession() { if (!currentSessionId) return; // NOTE: scrollY is stored under a separate key (writeScrollY). Storing @@ -5869,6 +7108,11 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, + pageUrl: location.pathname, + paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, }); } @@ -5898,31 +7142,33 @@ void main() { } function cleanup() { - // Hide the wrapper immediately so variants disappear. DON'T structurally - // mutate the DOM yet - HMR from the agent's source rewrite is on its way, - // and a manual replaceChild under React causes NotFoundError when the - // reconciler later tries to remove a wrapper we already removed. - // Schedule a 2s fallback that does the manual swap only if HMR hasn't - // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const cleanupSessionId = currentSessionId; - if (cleanupSessionId) { + if (svelteComponentSession?.sessionId === cleanupSessionId) { + teardownSvelteComponentSession(true); + } else if (cleanupSessionId) { + // Hide the wrapper immediately so variants disappear. DON'T structurally + // mutate the DOM yet - HMR from the agent's source rewrite is on its way, + // and a manual replaceChild under React causes NotFoundError when the + // reconciler later tries to remove a wrapper we already removed. + // Schedule a 2s fallback that does the manual swap only if HMR hasn't + // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); if (wrapper) wrapper.style.display = 'none'; - } - setTimeout(function() { - if (!cleanupSessionId) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!wrapper) return; - const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - wrapper.parentElement.replaceChild(content, wrapper); - return; + setTimeout(function() { + if (!cleanupSessionId) return; + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) return; + const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); + if (orig) { + const content = orig.firstElementChild; + if (content) { + lateWrapper.parentElement.replaceChild(content, lateWrapper); + return; + } } - } - wrapper.remove(); - }, 2000); + lateWrapper.remove(); + }, 2000); + } hideBar(); hideHighlight(); stopScrollTracking(); @@ -5931,6 +7177,7 @@ void main() { clearScrollY(); finalizeInsertSession(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5938,9 +7185,9 @@ void main() { state = 'PICKING'; } - // --------------------------------------------------------------------------- + // // Toast - // --------------------------------------------------------------------------- + // function showToast(message, duration) { if (toastEl) toastEl.remove(); @@ -5964,7 +7211,7 @@ void main() { }); toastEl.id = PREFIX + '-toast'; toastEl.textContent = message; - document.body.appendChild(toastEl); + uiAppend(toastEl); requestAnimationFrame(() => { toastEl.style.opacity = '1'; toastEl.style.transform = 'translateX(-50%) translateY(0)'; @@ -5978,22 +7225,70 @@ void main() { }, duration); } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. function resumeSession() { const wrapper = document.querySelector('[data-impeccable-variants]'); - if (!wrapper) { clearSession(); clearHandled(); return false; } + if (!wrapper) { + if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } const sessionId = wrapper.dataset.impeccableVariants; // Don't resume if this session was already accepted/discarded if (isSessionHandled(sessionId)) return false; + // Svelte component sessions can't be resumed by counting DOM children: the + // wrapper holds a single mount target, not [data-impeccable-variant] nodes, + // and a page reload unmounts every compiled variant. Counting children here + // would strand the bar in CYCLING at 0/0. If there's no live in-memory mount + // for this wrapper, it's an orphan (reload / failed mount): drop it and let + // the live-server's SSE re-inject the manifest if the session is still live. + if (wrapper.dataset.impeccablePreview === 'svelte-component' + && svelteComponentSession?.sessionId !== sessionId) { + wrapper.remove(); + if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } + + if (wrapper.dataset.impeccablePreview === 'svelte-component') { + if (!svelteComponentSession?.mountedVariant) { + return true; + } + currentSessionId = sessionId; + expectedVariants = Number(wrapper.dataset.impeccableVariantCount) + || Number(svelteComponentSession.manifest?.count) + || expectedVariants + || 1; + arrivedVariants = expectedVariants; + const saved = loadSession(); + applySavedSessionMeta(saved); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = svelteComponentSession.mountedVariant > 0 && svelteComponentSession.mountedVariant <= arrivedVariants + ? svelteComponentSession.mountedVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = resolveSvelteComponentAnchor() + || wrapper.parentElement; + state = 'CYCLING'; + hideShaderOverlay(); + showBar('cycling'); + startScrollTracking(); + refreshParamsPanel(); + saveSession(); + queueCheckpoint('browser_resumed_svelte_component'); + return true; + } + currentSessionId = sessionId; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -6002,6 +7297,7 @@ void main() { // Restore state from localStorage if available const saved = loadSession(); if (saved && saved.id === sessionId) { + applySavedSessionMeta(saved); visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0); if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; @@ -6072,9 +7368,9 @@ void main() { return true; } - // --------------------------------------------------------------------------- + // // Global bar (always visible at bottom) - // --------------------------------------------------------------------------- + // let globalBarEl = null; let globalBarBrandEl = null; @@ -6166,6 +7462,8 @@ void main() { let pageChatExpanded = false; let steerLocked = false; let steerRequestId = null; + let steerPendingMessage = ''; + let steerInputWasFocused = false; let pageChatDotsEl = null; let steerAwaitTimer = null; let voiceRecognition = null; @@ -6179,7 +7477,7 @@ void main() { const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; - const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect'; + const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect'; const GLOBAL_BAR_SECTION_GAP = 8; const GLOBAL_BAR_INNER_GAP = 2; const GLOBAL_BAR_INNER_PAD_LEFT = 2; @@ -6323,7 +7621,7 @@ void main() { const attempt = () => { steerFocusRecoverTimer = null; if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (pageHasHostTextSelection()) { steerFocusRecoverTimer = setTimeout(attempt, 120); return; @@ -6344,7 +7642,7 @@ void main() { steerFocusSuspended = true; steerFocusPauseUntil = performance.now() + STEER_PAGE_FOCUS_PAUSE_MS; pagePointerGesture = { x: e.clientX, y: e.clientY, dragged: false }; - if (pageChatInput && document.activeElement === pageChatInput) { + if (pageChatInput && activeElementDeep() === pageChatInput) { pageChatInput.blur(); } } @@ -6404,7 +7702,7 @@ void main() { pickActive, pageChatReady: !!pageChatInput, pageChatExpanded, - active: steerFocusTargetLabel(document.activeElement), + active: steerFocusTargetLabel(activeElementDeep()), shouldSteer: shouldFocusSteerChat(), ...(extra || {}), }); @@ -6423,26 +7721,26 @@ void main() { function focusConfigureInput(reason) { steerFocusLog('focusConfigureInput', { reason }); const inputId = configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input'; - const input = document.getElementById(inputId); + const input = uiGetById(inputId); if (!input) { steerFocusLog('focusConfigureInput missing', { reason }); return; } setTimeout(() => { - const before = document.activeElement; + const before = activeElementDeep(); input.focus(); steerFocusLog('focusConfigureInput result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== input, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== input, }); }, 60); } function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; - const focused = document.activeElement === pageChatInput; + const focused = activeElementDeep() === pageChatInput; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; const P = pageChatPalette(); pageChatEl.style.borderColor = steerLocked @@ -6476,15 +7774,15 @@ void main() { } syncPageChatVisual(); pageChatInput.style.pointerEvents = 'auto'; - const before = document.activeElement; + const before = activeElementDeep(); try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== pageChatInput, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== pageChatInput, }); } @@ -6515,6 +7813,37 @@ void main() { return wrap; } + function keepSteerPointerInside(e, opts = {}) { + e.stopPropagation(); + if (opts.preventDefault !== false) e.preventDefault(); + } + + function preparePageChatInputForTyping() { + if (!pageChatEl || !pageChatInput) return false; + pageChatExpanded = true; + pageChatEl.dataset.expanded = 'true'; + pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; + pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return true; + } + + function focusPageChatInput(reason) { + if (!preparePageChatInputForTyping() || steerLocked) return false; + try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } + const focused = activeElementDeep() === pageChatInput; + if (focused) steerInputWasFocused = true; + syncPageChatFocusRing(); + return focused; + } + function clearSteerAwaitTimer() { if (steerAwaitTimer) { clearTimeout(steerAwaitTimer); @@ -6528,6 +7857,7 @@ void main() { if (!steerLocked || steerRequestId !== id) return; unlockSteerChat({ error: 'Steer timed out waiting for the agent. Check that live-poll is running and replies with steer_done.', + restoreMessage: steerPendingMessage, }); }, STEER_AWAIT_TIMEOUT_MS); } @@ -6538,19 +7868,12 @@ void main() { steerLocked = true; pageChatEl.dataset.processing = 'true'; pageChatInput.disabled = true; - pageChatInput.value = ''; - pageChatInput.blur(); + preparePageChatInputForTyping(); if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = true; pageChatVoiceBtn.style.display = 'none'; } - pageChatExpanded = false; - pageChatEl.dataset.expanded = 'false'; - pageChatEl.style.width = PAGE_CHAT_PROCESSING_W; pageChatEl.style.cursor = 'default'; - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; pageChatInput.style.pointerEvents = 'none'; if (pageChatHint) { pageChatHint.style.display = 'none'; @@ -6568,17 +7891,26 @@ void main() { function unlockSteerChat(opts) { clearSteerAwaitTimer(); + const restoreMessage = typeof opts?.restoreMessage === 'string' ? opts.restoreMessage : ''; + const keepExpanded = Boolean(opts?.error && restoreMessage); steerLocked = false; + const completedId = steerRequestId; steerRequestId = null; if (!pageChatEl) return; pageChatEl.dataset.processing = 'false'; pageChatEl.removeAttribute('aria-busy'); pageChatEl.setAttribute('aria-label', 'Steer the page'); - pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W; + pageChatExpanded = keepExpanded; + pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false'; + pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W; pageChatEl.style.cursor = 'pointer'; if (pageChatInput) { pageChatInput.disabled = false; - pageChatInput.value = ''; + pageChatInput.value = keepExpanded ? restoreMessage : ''; + pageChatInput.style.width = keepExpanded ? '' : '0'; + pageChatInput.style.padding = keepExpanded ? '0 6px' : '0'; + pageChatInput.style.opacity = keepExpanded ? '1' : '0'; + pageChatInput.style.pointerEvents = 'auto'; } if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = false; @@ -6586,18 +7918,28 @@ void main() { } if (pageChatHint) { pageChatHint.textContent = 'Steer'; - pageChatHint.style.display = ''; - pageChatHint.style.visibility = ''; + pageChatHint.style.display = keepExpanded ? 'none' : ''; + pageChatHint.style.visibility = keepExpanded ? 'hidden' : ''; + pageChatHint.style.opacity = keepExpanded ? '0' : '1'; } if (pageChatDotsEl?.parentNode) { pageChatDotsEl.remove(); pageChatDotsEl = null; } + steerPendingMessage = keepExpanded ? restoreMessage : ''; + steerInputWasFocused = false; syncPageChatChrome(); syncPageChatFocusRing(); if (opts?.error) showToast(String(opts.error), 5000); else if (opts?.message) showToast(String(opts.message), 4000); - syncPageChatFocus('steer-unlock'); + if (completedId) { + sendSteerCheckpoint(completedId, opts?.error ? 'steer_error' : 'steer_done', { + message: opts?.message || opts?.error || '', + file: opts?.file || '', + }); + } + if (keepExpanded) focusPageChatInput('steer-error-restore'); + else syncPageChatFocus('steer-unlock'); } function steerSpeechRecognitionCtor() { @@ -6651,7 +7993,7 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = document.getElementById(PREFIX + '-configure-voice'); + const voiceBtn = uiGetById(PREFIX + '-configure-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -6784,7 +8126,7 @@ void main() { } function configureVoiceContext() { - const input = document.getElementById( + const input = uiGetById( configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input', ); return { @@ -6819,26 +8161,37 @@ void main() { if (!text || steerLocked) return; const id = id8(); steerRequestId = id; + steerPendingMessage = text; + if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); lockSteerChat(); scheduleSteerAwaitTimeout(id); + sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); sendEvent({ type: 'steer', id, message: text, pageUrl: location.href, }).then((res) => { - if (!res) unlockSteerChat({ error: 'Could not reach live server' }); + if (!res) { + sendSteerCheckpoint(id, 'steer_send_failed', { message: text }); + unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text }); + } }); } function maybeCompleteSteer(msg) { if (!steerRequestId || msg.id !== steerRequestId) return false; if (msg.type === 'steer_done') { - unlockSteerChat({ message: msg.message }); + unlockSteerChat({ message: msg.message, file: msg.file }); + if (msg.file && /\.svelte(?:$|\?)/.test(String(msg.file))) { + setTimeout(() => { + if (!steerLocked) showToast('Steer applied. Reload if the page has not refreshed yet.', 5000); + }, 4500); + } return true; } if (msg.type === 'error') { - unlockSteerChat({ error: msg.message || 'Steer failed' }); + unlockSteerChat({ error: msg.message || 'Steer failed', restoreMessage: steerPendingMessage }); return true; } return false; @@ -6847,21 +8200,10 @@ void main() { function expandPageChat(opts) { const focus = !opts || opts.focus !== false; if (!pageChatEl || !pageChatInput || steerLocked) return; - pageChatExpanded = true; - pageChatEl.dataset.expanded = 'true'; - pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; - pageChatEl.style.cursor = 'text'; - if (pageChatHint) { - pageChatHint.style.display = 'none'; - pageChatHint.style.opacity = '0'; - } - pageChatInput.style.width = ''; - pageChatInput.style.padding = '0 6px'; - pageChatInput.style.opacity = '1'; - pageChatInput.style.pointerEvents = 'auto'; + preparePageChatInputForTyping(); syncPageChatChrome(); syncPageChatFocusRing(); - if (focus) pageChatInput.focus(); + if (focus) focusPageChatInput('expand-page-chat'); } function collapsePageChat(opts) { @@ -6878,7 +8220,7 @@ void main() { } else { pageChatInput.style.pointerEvents = 'auto'; } - if (pageChatHint && document.activeElement !== pageChatInput) { + if (pageChatHint && activeElementDeep() !== pageChatInput) { pageChatHint.style.display = ''; pageChatHint.style.opacity = '1'; } @@ -6952,7 +8294,7 @@ void main() { pageChatEl.appendChild(pageChatInput); pageChatEl.appendChild(pageChatVoiceBtn); - if (!document.getElementById(PREFIX + '-page-chat-style')) { + if (!uiGetById(PREFIX + '-page-chat-style')) { const s = document.createElement('style'); s.id = PREFIX + '-page-chat-style'; s.textContent = @@ -6966,23 +8308,34 @@ void main() { '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } - pageChatEl.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { + keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat(); + expandPageChat({ focus: false }); + focusPageChatInput('page-chat-click'); }); - pageChatVoiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); + pageChatVoiceBtn.addEventListener('mousedown', keepSteerPointerInside); pageChatVoiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); + keepSteerPointerInside(e); if (steerLocked) return; toggleSteerVoice(); }); + pageChatInput.addEventListener('pointerdown', keepSteerPointerInside); + pageChatInput.addEventListener('mousedown', keepSteerPointerInside); + pageChatInput.addEventListener('click', (e) => { + keepSteerPointerInside(e); + if (!steerLocked) focusPageChatInput('page-chat-input-click'); + }); + pageChatInput.addEventListener('input', () => { syncPageChatVisual(); }); @@ -6995,7 +8348,7 @@ void main() { syncPageChatFocusRing(); setTimeout(() => { if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (!pageChatInput.value.trim()) collapsePageChat(); scheduleSteerFocusRecover('steer-blur-recover'); }, 120); @@ -7039,7 +8392,7 @@ void main() { globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false'; globalBarBrandEl.setAttribute('aria-label', connected ? 'Impeccable live mode' - : 'Impeccable live mode: agent not polling'); + : 'Impeccable live mode - agent not polling'); globalBarBrandEl.removeAttribute('title'); globalBarBrandEl.style.cursor = connected ? 'default' : 'help'; const mark = globalBarBrandEl.querySelector('[data-brand-mark]'); @@ -7077,7 +8430,7 @@ void main() { }); agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip'; agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP; - document.body.appendChild(agentPollTooltipEl); + uiAppend(agentPollTooltipEl); return agentPollTooltipEl; } @@ -7131,7 +8484,7 @@ void main() { // Custom focus-visible for bar buttons. Browser default is a heavy // blue ring that looks jarring on the dark capsule. Replace with a // soft accent-tinted inner ring that respects the bar's palette. - if (!document.getElementById(PREFIX + '-bar-focus-style')) { + if (!uiGetById(PREFIX + '-bar-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-bar-focus-style'; s.textContent = @@ -7143,7 +8496,7 @@ void main() { '@keyframes impeccable-agent-dot { 0%, 100% { opacity: 0.45; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1); } }' + '#' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: impeccable-agent-dot 1.4s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: none; opacity: 0.9; } }'; - document.head.appendChild(s); + uiAppendStyle(s); } globalBarEl = el('div', { @@ -7176,7 +8529,7 @@ void main() { brand.id = PREFIX + '-global-bar-brand'; brand.dataset.agentConnected = 'false'; brand.setAttribute('role', 'img'); - brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling'); + brand.setAttribute('aria-label', 'Impeccable live mode - agent not polling'); const brandMark = el('span', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -7211,7 +8564,7 @@ void main() { inner.id = PREFIX + '-global-bar-inner'; globalBarEl.appendChild(inner); - // --- button factory: icon-only at rest, label slides in on hover/active --- + // Button factory: icon-only at rest, label slides in on hover/active. function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { const b = el('button', { position: 'relative', @@ -7506,6 +8859,7 @@ void main() { color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', }); + exitBtn.id = PREFIX + '-exit'; exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = 'oklch(58% 0.15 35)'; exitBtn.style.background = P.exitHover; }); @@ -7530,8 +8884,8 @@ void main() { try { window.focus(); } catch { /* in-app preview may block */ } }, true); - document.body.appendChild(pendingDockEl); - document.body.appendChild(globalBarEl); + uiAppend(pendingDockEl); + uiAppend(globalBarEl); defangOutsideHandlers(pendingDockEl); defangOutsideHandlers(globalBarEl); @@ -7553,11 +8907,11 @@ void main() { } function updateGlobalBarState() { - const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); - const detectBadge = document.getElementById(PREFIX + '-detect-badge'); - const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); - const insertToggle = document.getElementById(PREFIX + '-insert-toggle'); - const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const detectToggle = uiGetById(PREFIX + '-detect-toggle'); + const detectBadge = uiGetById(PREFIX + '-detect-badge'); + const pickToggle = uiGetById(PREFIX + '-pick-toggle'); + const insertToggle = uiGetById(PREFIX + '-insert-toggle'); + const designToggle = uiGetById(PREFIX + '-design-toggle'); const theme = globalBarEl?.dataset.theme || 'light'; const P = barPaletteForTheme(theme); @@ -7751,8 +9105,9 @@ void main() { pendingApplyInFlight = false; } if (globalBarEl) { - globalBarEl.style.transform = 'translateY(100%)'; - setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300); + globalBarEl.style.transition = 'none'; + globalBarEl.remove(); + globalBarEl = null; } pageChatEl = null; pageChatInput = null; @@ -7765,6 +9120,7 @@ void main() { if (barEl) { barEl.remove(); barEl = null; } if (pickerEl) { pickerEl.remove(); pickerEl = null; } if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; } + if (editBadgeProxyRoot) { editBadgeProxyRoot.remove(); editBadgeProxyRoot = null; editBadgeProxyByTarget = new Map(); } if (evtSource) { evtSource.close(); evtSource = null; } document.removeEventListener('mousemove', handleMouseMove, true); document.removeEventListener('click', handleClick, true); @@ -7777,9 +9133,9 @@ void main() { console.log('[impeccable] Live mode exited.'); } - // --------------------------------------------------------------------------- + // // Design System Panel - visualizes the project's .impeccable/design.json sidecar - // --------------------------------------------------------------------------- + // const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; const DESIGN_PANEL_WIDTH = 440; @@ -7847,7 +9203,7 @@ void main() { root.className = 'root'; designShadow.appendChild(root); - document.body.appendChild(designHost); + uiAppend(designHost); // The host is pointer-events: none; the panel inside the shadow DOM // manages its own auto/none. Events bubble through the shadow boundary, // so attaching here silences host-page outside-interaction handlers @@ -7889,7 +9245,7 @@ void main() { .root * { box-sizing: border-box; } button { font: inherit; color: inherit; } - /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + /* Panel shell: chrome matches the bar; body canvas stays neutral */ .panel { position: fixed; top: 12px; bottom: 72px; right: 12px; width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); @@ -7955,7 +9311,7 @@ void main() { .panel-body::-webkit-scrollbar { width: 8px; } .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } - /* --- States --- */ + /* States */ .empty, .loading, .error { margin: 16px 4px; padding: 28px 20px; text-align: center; @@ -7966,7 +9322,7 @@ void main() { .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; } .error { color: oklch(45% 0.15 25); } - /* --- Stale hint --- */ + /* Stale hint */ .stale { display: flex; align-items: center; gap: 8px; margin: 8px 4px 12px; @@ -7979,7 +9335,7 @@ void main() { .stale-text { flex: 1; min-width: 0; } .stale-text strong { color: ${DP.ink}; font-weight: 600; } - /* --- Parsed-md fallback banner --- */ + /* Parsed-md fallback banner */ .parsed-md-cta { margin: 8px 4px 14px; padding: 14px 16px; @@ -7991,7 +9347,7 @@ void main() { .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; } .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; } - /* --- Tile primitives --- */ + /* Tile primitives */ .tile { position: relative; background: ${DP.tile}; @@ -8010,7 +9366,7 @@ void main() { } .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; } - /* --- Color tile --- */ + /* Color tile */ .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; } .c-tile:hover { transform: translateY(-1px); } .c-hero { @@ -8025,7 +9381,7 @@ void main() { .c-ramp > span { flex: 1; } .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Type tile --- */ + /* Type tile */ .t-tile { } .t-specimen { margin: 4px 0 6px; @@ -8035,7 +9391,7 @@ void main() { .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; } .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Shadow tile --- */ + /* Shadow tile */ .s-tile { } .s-surface { height: 60px; margin: 8px 2px 10px; @@ -8045,14 +9401,14 @@ void main() { .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; } .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; } - /* --- Radii strip --- */ + /* Radii strip */ .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; } .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; } .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); } .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; } .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; } - /* --- Component tile (hosts live primitives) --- */ + /* Component tile (hosts live primitives) */ .cmp-tile { } .cmp-stage { margin: 12px -4px 0; @@ -8066,7 +9422,7 @@ void main() { .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; } .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; } - /* --- Collapsible --- */ + /* Collapsible */ .coll { margin: 0 4px 8px; background: ${DP.tile}; @@ -8131,7 +9487,7 @@ void main() { .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; } .coll .overview-body li { margin-bottom: 3px; } - /* --- raw tab markdown (unchanged layout, neutralized palette) --- */ + /* raw tab markdown (unchanged layout, neutralized palette) */ .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; } .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; } .md h1 { font-size: 18px; } @@ -8303,7 +9659,7 @@ void main() { return box; } - // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 --- + // Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 function renderDesignVisual(body, parsed, sidecar) { const frontmatter = parsed?.frontmatter || {}; @@ -8667,7 +10023,7 @@ void main() { return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components'); } - // --- Collapsibles --------------------------------------------------------- + // Collapsibles. function buildCollapsible(key, label, count) { const wrap = document.createElement('div'); @@ -8775,7 +10131,7 @@ void main() { return s.replace(/\s+#.*$/, '').trim(); } - // --- Raw tab: minimal markdown renderer (subset) -------------------------- + // Raw tab: minimal markdown renderer (subset) function renderRawTab(body, md) { const wrap = document.createElement('div'); @@ -8908,9 +10264,9 @@ void main() { } catch { /* ignore */ } } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // function init() { try { history.scrollRestoration = 'manual'; } catch {} diff --git a/.opencode/skills/impeccable/scripts/live-completion.mjs b/.opencode/skills/impeccable/scripts/live-completion.mjs index 86b637fff..986773066 100644 --- a/.opencode/skills/impeccable/scripts/live-completion.mjs +++ b/.opencode/skills/impeccable/scripts/live-completion.mjs @@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.mode === 'error') return 'error'; + if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error'; return 'agent_done'; } diff --git a/.opencode/skills/impeccable/scripts/live-inject.mjs b/.opencode/skills/impeccable/scripts/live-inject.mjs index b9d3df41d..3a1f36e46 100644 --- a/.opencode/skills/impeccable/scripts/live-inject.mjs +++ b/.opencode/skills/impeccable/scripts/live-inject.mjs @@ -17,11 +17,38 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './impeccable-paths.mjs'; +import { + applySvelteKitLiveAdapter, + detectSvelteKitProject, + removeSvelteKitLiveAdapter, +} from './live-sveltekit-adapter.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start'; +const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end'; + +export const LIVE_IGNORE_PATTERNS = Object.freeze([ + '.impeccable/hook.cache.json', + '.impeccable/live/server.json', + '.impeccable/live/sessions/', + '.impeccable/live/previews/', + '.impeccable/live/annotations/', + '.impeccable/live/cache/', + '.impeccable/live/manual-edit-apply-transaction.json', + '.impeccable/live/manual-edit-events.jsonl', + '.impeccable/live/manual-edit-evidence/', + '.impeccable/live/pending-manual-edits.json', + '.impeccable/live/deferred-svelte-component-accepts.json', + '.impeccable-live.json', + '.impeccable-live/', + 'node_modules/.impeccable-live/', + 'src/lib/impeccable/ImpeccableLiveRoot.svelte', + 'src/lib/impeccable/__runtime.js', + 'src/lib/impeccable/[0-9a-f]*/', +]); /** * Hard-excluded directory patterns. These are NEVER user-facing pages and @@ -83,8 +110,14 @@ Output (JSON): validateConfig(config); const resolvedFiles = resolveFiles(process.cwd(), config); + const svelteKit = detectSvelteKitProject(process.cwd(), config); if (args.includes('--remove')) { + if (svelteKit) { + const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config }); + console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; @@ -110,6 +143,13 @@ Output (JSON): console.error(JSON.stringify({ ok: false, error: 'missing_port' })); process.exit(1); } + const gitIgnore = ensureLiveGitIgnores(process.cwd()); + + if (svelteKit) { + const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config }); + console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); @@ -129,10 +169,68 @@ Output (JSON): }; }); const anyInserted = results.some((r) => r.inserted); - console.log(JSON.stringify({ ok: anyInserted, port, results })); + console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results })); if (!anyInserted) process.exit(1); } +export function ensureLiveGitIgnores(cwd = process.cwd()) { + const target = resolveIgnoreTarget(cwd); + const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : ''; + const block = [ + IGNORE_MARKER_OPEN, + ...LIVE_IGNORE_PATTERNS, + IGNORE_MARKER_CLOSE, + ].join('\n'); + const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`); + + let updated; + if (markerRe.test(existing)) { + updated = existing.replace(markerRe, block); + } else { + const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n'; + updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`; + } + + if (updated !== existing) { + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + fs.writeFileSync(target.path, updated, 'utf-8'); + } + + return { + file: path.relative(cwd, target.path).split(path.sep).join('/'), + mode: target.mode, + changed: updated !== existing, + patterns: [...LIVE_IGNORE_PATTERNS], + }; +} + +function resolveIgnoreTarget(cwd) { + const gitExcludePath = resolveGitInfoExcludePath(cwd); + if (gitExcludePath) { + return { path: gitExcludePath, mode: 'git-info-exclude' }; + } + return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' }; +} + +function resolveGitInfoExcludePath(cwd) { + const dotGit = path.join(cwd, '.git'); + if (!fs.existsSync(dotGit)) return null; + + const stat = fs.statSync(dotGit); + if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude'); + if (!stat.isFile()) return null; + + const body = fs.readFileSync(dotGit, 'utf-8').trim(); + const match = body.match(/^gitdir:\s*(.+)$/i); + if (!match) return null; + const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]); + return path.join(gitDir, 'info', 'exclude'); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Expand config.files (which may contain glob patterns) into a literal list * of existing file paths relative to rootDir. Literal entries pass through; diff --git a/.opencode/skills/impeccable/scripts/live-insert.mjs b/.opencode/skills/impeccable/scripts/live-insert.mjs index 09d4d55be..0658e9914 100644 --- a/.opencode/skills/impeccable/scripts/live-insert.mjs +++ b/.opencode/skills/impeccable/scripts/live-insert.mjs @@ -21,6 +21,11 @@ import { buildCssAuthoring, buildCssSelectorPrefixExamples, } from './live-wrap.mjs'; +import { + buildSvelteComponentCssAuthoring, + scaffoldSvelteComponentInsertSession, + shouldUseSvelteComponentInjection, +} from './live-svelte-component.mjs'; const INSERT_POSITIONS = new Set(['before', 'after']); @@ -192,6 +197,41 @@ Output (JSON): const styleMode = detectStyleMode(targetFile); const isJsx = commentSyntax.open === '{/*'; const spliceIndex = computeInsertLine(startLine, endLine, position); + const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); + + if (shouldUseSvelteComponentInjection(targetFile)) { + const session = scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile: relTargetFile, + insertLine: spliceIndex + 1, + position, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + anchorLines: lines.slice(startLine, endLine + 1), + cwd: process.cwd(), + }); + console.log(JSON.stringify({ + mode: 'insert', + position, + file: session.manifestFile, + sourceFile: relTargetFile, + previewMode: 'svelte-component', + componentDir: session.componentDir, + propContract: session.propContract, + insertLine: 1, + sourceInsertLine: spliceIndex + 1, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + commentSyntax, + styleMode: 'svelte-component', + styleTag: null, + cssSelectorPrefixExamples: [], + cssAuthoring: buildSvelteComponentCssAuthoring(count), + })); + return; + } + const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? ''; @@ -216,7 +256,7 @@ Output (JSON): console.log(JSON.stringify({ mode: 'insert', position, - file: path.relative(process.cwd(), targetFile), + file: relTargetFile, insertLine: insertLine + 1, commentSyntax, styleMode: styleMode.mode, diff --git a/.opencode/skills/impeccable/scripts/live-poll.mjs b/.opencode/skills/impeccable/scripts/live-poll.mjs index fad836612..1e1259fbc 100644 --- a/.opencode/skills/impeccable/scripts/live-poll.mjs +++ b/.opencode/skills/impeccable/scripts/live-poll.mjs @@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs'; // that ceiling and loop in `pollOnce` to synthesize a long poll without // depending on the standalone undici package. export const PER_REQUEST_TIMEOUT_MS = 270_000; +export const DEFAULT_EVENT_LEASE_MS = 600_000; const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); @@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) { ? totalDeadline - Date.now() : PER_REQUEST_TIMEOUT_MS; const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); - const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); + const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`); if (res.status === 401) { const err = new Error('Authentication failed. The server token may have changed.'); @@ -317,7 +318,7 @@ Modes: Options: --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) - --file PATH Attach a source file path to the reply (generate flow) + --file PATH Attach a source file path to the reply (generate/steer flow) --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --help Show this help message diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs index 16c8285b9..cd1091b88 100644 --- a/.opencode/skills/impeccable/scripts/live-server.mjs +++ b/.opencode/skills/impeccable/scripts/live-server.mjs @@ -42,6 +42,10 @@ import { } from './live-manual-edits-buffer.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs'; +import { + applyDeferredSvelteComponentAccepts, + removeAllSvelteComponentSessions, +} from './live-svelte-component.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated @@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; +const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2; const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); function tombstoneTimedOutApplyId(eventId, details = {}) { @@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) { return entry.event; } entry.leaseUntil = Date.now() + leaseMs; + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return entry.event; } @@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) { const acknowledged = state.pendingEvents[idx].event; state.pendingEvents.splice(idx, 1); scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return acknowledged; } +function findPendingEventById(id) { + if (!id) return null; + const entry = state.pendingEvents.find((item) => item.event?.id === id); + return entry?.event || null; +} + function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; return `live-poll.mjs --reply ${id} done --data ''`; @@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) { return summary; } +function summarizeActiveSessionForClient(snapshot = {}) { + return { + id: snapshot.id, + phase: snapshot.phase, + pageUrl: snapshot.pageUrl ?? null, + sourceFile: snapshot.sourceFile ?? null, + previewFile: snapshot.previewFile ?? null, + previewMode: snapshot.previewMode ?? null, + expectedVariants: snapshot.expectedVariants ?? 0, + arrivedVariants: snapshot.arrivedVariants ?? 0, + visibleVariant: snapshot.visibleVariant ?? null, + checkpointRevision: snapshot.checkpointRevision ?? 0, + paramValues: snapshot.paramValues || {}, + }; +} + +function activeSessionSummaries() { + if (!state.sessionStore) return []; + return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot)); +} + +function cancelQueuedAnonymousExitEvents() { + let removed = 0; + for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) { + const event = state.pendingEvents[i]?.event; + if (event?.type !== 'exit' || event.id) continue; + state.pendingEvents.splice(i, 1); + removed += 1; + } + if (removed > 0) { + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); + } + return removed; +} + function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { const canceledById = new Map(); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); @@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() { clearTimeout(state.leaseTimer); state.leaseTimer = null; } - if (state.pendingPolls.length === 0) return; const now = Date.now(); const nextLeaseUntil = state.pendingEvents .map((entry) => entry.leaseUntil || 0) @@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() { state.leaseTimer = setTimeout(() => { state.leaseTimer = null; flushPendingPolls(); - }, Math.max(0, nextLeaseUntil - now)); + broadcastAgentPollingIfChanged(); + }, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS)); } function flushPendingPolls() { @@ -1032,7 +1082,9 @@ function flushPendingPolls() { } function agentPollingConnected() { - return state.pendingPolls.length > 0; + const now = Date.now(); + return state.pendingPolls.length > 0 + || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now); } function broadcastAgentPollingIfChanged() { @@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/status') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } - const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; + const sessions = activeSessionSummaries(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', @@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/events' && req.method === 'GET') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + clearTimeout(state.exitTimer); + state.exitTimer = null; + cancelQueuedAnonymousExitEvents(); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { type: 'connected', hasProjectContext: hasProjectContext(), agentPolling: agentPollingConnected(), + activeSessions: activeSessionSummaries(), }) + '\n\n'); state.sseClients.add(res); - clearTimeout(state.exitTimer); // Keepalive: SSE comment every 30s prevents silent connection drops. const heartbeat = setInterval(() => { @@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } } + if (msg.type === 'exit') { + cleanupSvelteComponentSessionsBeforeExit(); + } if (msg.type !== 'checkpoint') { enqueueEvent(msg); } @@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) { }); } +function sessionFileMetadataFromPollReply(file) { + if (!file || typeof file !== 'string') return { file }; + const normalized = file.split(path.sep).join('/'); + const base = { file: normalized }; + if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base; + if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base; + + let full; + try { + full = path.resolve(process.cwd(), normalized); + const rel = path.relative(process.cwd(), full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base; + } catch { + return base; + } + + try { + const manifest = JSON.parse(fs.readFileSync(full, 'utf-8')); + if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base; + return { + file: String(manifest.sourceFile).split(path.sep).join('/'), + sourceFile: String(manifest.sourceFile).split(path.sep).join('/'), + previewFile: normalized, + previewMode: 'svelte-component', + }; + } catch { + return base; + } +} + function handlePollPost(req, res) { let body = ''; req.on('data', (c) => { body += c; }); @@ -1965,6 +2053,16 @@ function handlePollPost(req, res) { res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); return; } + const pendingEventBeforeAck = findPendingEventById(msg.id); + if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done' + && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'steer_done_requires_file_or_message', + hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.', + })); + return; + } const acknowledgedEvent = acknowledgePendingEvent(msg.id); let skipJournalReply = false; let existingSession = null; @@ -1987,6 +2085,7 @@ function handlePollPost(req, res) { })); return; } + const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); if (state.sessionStore && msg.id && !skipJournalReply) { try { const eventType = msg.type === 'steer_done' @@ -2001,7 +2100,10 @@ function handlePollPost(req, res) { state.sessionStore.appendEvent({ type: eventType, id: msg.id, - file: msg.file, + file: replyFileMeta.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, message: msg.message, sourceEventType: acknowledgedEvent?.type, carbonize: msg.data?.carbonize === true, @@ -2010,7 +2112,16 @@ function handlePollPost(req, res) { } flushPendingPolls(); // Forward the reply to the browser via SSE - broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + broadcast({ + type: msg.type || 'done', + id: msg.id, + message: msg.message, + file: msg.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, + data: msg.data, + }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); @@ -2023,6 +2134,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { + cleanupSvelteComponentSessionsBeforeExit(); removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; @@ -2037,6 +2149,25 @@ function shutdown() { process.exit(0); } +function cleanupSvelteComponentSessionsBeforeExit() { + try { + removeAllSvelteComponentSessions(process.cwd()); + } catch (err) { + console.warn('[impeccable] Svelte component session cleanup failed:', err.message); + } +} + +function applyLegacyDeferredAcceptsOnStartup() { + try { + const result = applyDeferredSvelteComponentAccepts(process.cwd()); + if (result.applied > 0 || result.failed > 0) { + console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result)); + } + } catch (err) { + console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({ cwd: process.cwd(), reason: 'manual_edit_server_start_recovered_abandoned_transaction', }); +applyLegacyDeferredAcceptsOnStartup(); restorePendingEventsFromStore(); pruneStaleManualApplyEvidence(process.cwd()); const portArg = args.find(a => a.startsWith('--port=')); diff --git a/.opencode/skills/impeccable/scripts/live-session-store.mjs b/.opencode/skills/impeccable/scripts/live-session-store.mjs index 7562e3d5b..5ec4d34d4 100644 --- a/.opencode/skills/impeccable/scripts/live-session-store.mjs +++ b/.opencode/skills/impeccable/scripts/live-session-store.mjs @@ -106,6 +106,8 @@ function baseSnapshot(id) { phase: 'new', pageUrl: null, sourceFile: null, + previewFile: null, + previewMode: null, expectedVariants: 0, arrivedVariants: 0, visibleVariant: null, @@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; - next.sourceFile = event.file ?? next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0); next.pendingEventSeq = null; next.pendingEvent = null; if (event.carbonize === true) { @@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { } break; case 'checkpoint': + if (COMPLETED_PHASES.has(next.phase)) { + next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null }); + break; + } if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { next.phase = event.phase ?? next.phase; next.checkpointRevision = event.revision ?? next.checkpointRevision; next.activeOwner = event.owner ?? next.activeOwner; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.visibleVariant = event.visibleVariant ?? next.visibleVariant; + next.sourceFile = event.sourceFile ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; if (event.paramValues) next.paramValues = { ...event.paramValues }; } else { next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); @@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'steer_done': next.phase = 'steer_done'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.message = event.message ?? next.message; next.pendingEventSeq = null; next.pendingEvent = null; break; @@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'complete': next.phase = 'completed'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; next.pendingEventSeq = null; next.pendingEvent = null; break; diff --git a/.opencode/skills/impeccable/scripts/live-svelte-component.mjs b/.opencode/skills/impeccable/scripts/live-svelte-component.mjs new file mode 100644 index 000000000..dc35dc0ce --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live-svelte-component.mjs @@ -0,0 +1,826 @@ +/** + * Svelte live-mode component injection helpers. + * + * Variants are real .svelte components under node_modules/.impeccable-live//. + * The browser mounts them via Svelte 5 mount(); accept inlines the chosen + * variant back into the route source with props mapped to original bindings. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; + +export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live'; +export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`; +export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json'; + +const MUSTACHE_RE = /\{([^{}]+)\}/g; + +export function shouldUseSvelteComponentInjection(filePath) { + if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false; + return path.extname(filePath).toLowerCase() === '.svelte'; +} + +export function componentSessionDir(id, cwd = process.cwd()) { + return path.join(cwd, SVELTE_COMPONENT_ROOT, id); +} + +export function manifestPathForSession(id, cwd = process.cwd()) { + return path.join(componentSessionDir(id, cwd), 'manifest.json'); +} + +export function ensureRuntimeHelper(cwd = process.cwd()) { + const file = path.join(cwd, SVELTE_RUNTIME_FILE); + if (fs.existsSync(file)) return file; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8'); + return file; +} + +/** + * Extract ordered unique mustache expressions from markup (not inside ). + */ +export function extractMustacheExpressions(text) { + const expressions = []; + const seen = new Set(); + const lines = String(text || '').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('\n` + : ''; + return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n\n`; +} + +function buildInsertVariantStub(variantNum) { + return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; +} + +export function scaffoldSvelteComponentSession({ + id, + count, + sourceFile, + sourceStartLine, + sourceEndLine, + originalLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const originalMarkup = originalLines.join('\n'); + const contract = buildPropContract(extractMustacheExpressions(originalMarkup)); + const originalWithProps = substituteExprsWithProps(originalMarkup, contract); + + const manifest = { + id, + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + sourceStartLine, + sourceEndLine, + count, + propContract: contract, + originalMarkup, + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: contract, + }; +} + +export function scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile, + insertLine, + position, + anchorStartLine, + anchorEndLine, + anchorLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const anchorMarkup = (anchorLines || []).join('\n'); + const manifest = { + id, + mode: 'insert', + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + insertLine, + position, + anchorStartLine, + anchorEndLine, + originalMarkup: anchorMarkup, + anchorMarkup, + count, + propContract: [], + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: [], + }; +} + +export function findSvelteComponentManifest(id, cwd = process.cwd()) { + const direct = manifestPathForSession(id, cwd); + if (fs.existsSync(direct)) { + return readManifest(direct); + } + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return null; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(root, entry.name, 'manifest.json'); + if (!fs.existsSync(candidate)) continue; + try { + const manifest = readManifest(candidate); + if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; + } catch { /* skip */ } + } + return null; +} + +export function readManifest(manifestPath) { + const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + return { + ...data, + manifestPath, + }; +} + +export function resolveSourceFile(sourceFile, cwd = process.cwd()) { + if (!sourceFile || path.isAbsolute(sourceFile)) { + throw new Error('Invalid svelte-component source file'); + } + const full = path.resolve(cwd, sourceFile); + const rel = path.relative(cwd, full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('Svelte-component source file escapes project root'); + } + if (!fs.existsSync(full)) { + throw new Error('Svelte-component source file not found: ' + sourceFile); + } + return full; +} + +function appendCssToSvelteStyle(lines, cssLines) { + const closeIdx = findLastStyleCloseLine(lines); + const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; + if (closeIdx === -1) { + return [...lines, '', '']; + } + return [ + ...lines.slice(0, closeIdx), + ...prepared, + ...lines.slice(closeIdx), + ]; +} + +function findLastStyleCloseLine(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + if (/<\/style\s*>/.test(lines[i])) return i; + } + return -1; +} + +function bakeParamValuesInCss(cssLines, paramValues) { + if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; + return cssLines.map((line) => { + let out = line; + for (const [key, value] of Object.entries(paramValues)) { + const varName = `--p-${key}`; + out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); + } + return out; + }); +} + +function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { + const css = String((cssLines || []).join('\n')); + if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; + + const rules = parseCssRules(css); + const output = []; + for (const rule of rules) { + appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); + } + return output.join('\n') + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ''); +} + +function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { + const prelude = rule.prelude.trim(); + const body = rule.body.trim(); + if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; + + if (/^@scope\b/i.test(prelude)) { + if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; + const inner = parseCssRules(body); + for (const innerRule of inner) { + const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); + if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; + output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); + } + return; + } + + const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); + if (!rewrittenPrelude) return; + output.push(formatCssRule(rewrittenPrelude, body)); +} + +function parseCssRules(css) { + const rules = []; + const text = String(css || ''); + let i = 0; + while (i < text.length) { + while (i < text.length && /\s/.test(text[i])) i++; + const preludeStart = i; + while (i < text.length && text[i] !== '{') i++; + if (i >= text.length) break; + const prelude = text.slice(preludeStart, i).trim(); + i++; + const bodyStart = i; + let depth = 1; + let quote = null; + let comment = false; + while (i < text.length && depth > 0) { + const ch = text[i]; + const next = text[i + 1]; + if (comment) { + if (ch === '*' && next === '/') { + comment = false; + i += 2; + continue; + } + i++; + continue; + } + if (quote) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === quote) quote = null; + i++; + continue; + } + if (ch === '/' && next === '*') { + comment = true; + i += 2; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); + if (prelude) rules.push({ prelude, body }); + } + return rules; +} + +function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { + const selectors = splitSelectorList(prelude); + const rewritten = []; + for (const selector of selectors) { + const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); + if (next) rewritten.push(next); + } + return rewritten.join(', '); +} + +function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { + let out = selector.trim(); + const hasVariant = /data-impeccable-variant/.test(out); + if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; + if (hasVariant) { + out = out.replace(variantSelectorRegex(variantNum), ''); + out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); + } + + const paramResult = rewriteParamSelectors(out, paramValues); + if (!paramResult.keep) return ''; + out = paramResult.selector; + + out = out + .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') + .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') + .replace(/\s+/g, ' ') + .trim(); + + out = out.replace(/^[>+~]\s*/, '').trim(); + if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; + return out; +} + +function rewriteParamSelectors(selector, paramValues) { + let keep = true; + const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { + if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; + const actual = paramValues[key]; + if (expected != null && String(actual) !== String(expected)) { + keep = false; + return ''; + } + if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { + keep = false; + return ''; + } + return ''; + }); + return { keep, selector: next }; +} + +function splitSelectorList(prelude) { + const selectors = []; + let start = 0; + let bracket = 0; + let paren = 0; + let quote = null; + for (let i = 0; i < prelude.length; i++) { + const ch = prelude[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '[') bracket++; + else if (ch === ']') bracket = Math.max(0, bracket - 1); + else if (ch === '(') paren++; + else if (ch === ')') paren = Math.max(0, paren - 1); + else if (ch === ',' && bracket === 0 && paren === 0) { + selectors.push(prelude.slice(start, i)); + start = i + 1; + } + } + selectors.push(prelude.slice(start)); + return selectors; +} + +function selectorHasVariant(selector, variantNum) { + return variantSelectorRegex(variantNum).test(selector); +} + +function variantSelectorRegex(variantNum) { + return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); +} + +function formatCssRule(selector, body) { + return `${selector} { ${body.trim()} }`; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { + const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); + const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); + const resultBase = { + file: manifest.sourceFile, + sourceFile: manifest.sourceFile, + previewMode: 'svelte-component', + componentDir: manifest.componentDir, + carbonize: false, + }; + if (!fs.existsSync(variantPath)) { + return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; + } + + const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); + if (manifest.mode === 'insert') { + return inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, + }); + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const contract = manifest.propContract || []; + const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); + const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract) + .split('\n') + .map((line) => line.trimEnd()); + + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const start = Number(manifest.sourceStartLine) - 1; + const end = Number(manifest.sourceEndLine) - 1; + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { + return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; + } + + const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, start), + ...indentedMarkup, + ...sourceLines.slice(end + 1), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, +}) { + if (!svelteMarkupHasVisibleContent(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; + } + if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const restoredMarkup = String(markup || '') + .split('\n') + .map((line) => line.trimEnd()); + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const insertIndex = Number(manifest.insertLine) - 1; + if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { + return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; + } + + const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; + const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, insertIndex), + ...indentedMarkup, + ...sourceLines.slice(insertIndex), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function svelteMarkupHasVisibleContent(markup) { + const text = String(markup || '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 0) return true; + return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); +} + +function mergeOriginalTopLevelAttrs(markup, originalMarkup) { + const variantOpen = matchOpeningTag(markup); + const originalOpen = matchOpeningTag(originalMarkup); + if (!variantOpen || !originalOpen) return markup; + if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; + + const variantAttrs = parseAttrSegments(variantOpen.attrs); + const originalAttrs = parseAttrSegments(originalOpen.attrs); + const additions = []; + let attrs = variantOpen.attrs; + + const originalClass = originalAttrs.get('class'); + const variantClass = variantAttrs.get('class'); + if (originalClass && variantClass) { + const merged = mergeStaticClassAttr(originalClass, variantClass); + if (merged) { + attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); + variantAttrs.set('class', { ...variantClass, raw: merged }); + } + } else if (originalClass && !variantClass) { + additions.push(originalClass.raw); + } + + for (const [name, attr] of originalAttrs) { + if (name === 'class') continue; + if (!variantAttrs.has(name)) additions.push(attr.raw); + } + + if (additions.length === 0 && attrs === variantOpen.attrs) return markup; + const nextOpen = variantOpen.prefix + + variantOpen.tag + + attrs + + additions.map((attr) => ' ' + attr.trim()).join('') + + variantOpen.close; + return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); +} + +function matchOpeningTag(markup) { + const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); + if (!match) return null; + return { + raw: match[0], + prefix: match[1], + tag: match[2], + attrs: match[3] || '', + close: match[4], + index: match.index || 0, + }; +} + +function parseAttrSegments(attrs) { + const out = new Map(); + const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; + let match; + while ((match = re.exec(attrs))) { + const raw = match[0]; + const name = match[1]; + out.set(name, { + name, + raw, + start: match.index, + end: match.index + raw.length, + }); + } + return out; +} + +function mergeStaticClassAttr(originalClass, variantClass) { + const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + if (!originalValue || !variantValue) return null; + const quote = variantValue[1]; + const classes = [ + ...variantValue[2].split(/\s+/), + ...originalValue[2].split(/\s+/), + ].filter(Boolean); + return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; +} + +export function removeSvelteComponentSession(id, cwd = process.cwd()) { + const dir = componentSessionDir(id, cwd); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* non-fatal */ } +} + +export function removeAllSvelteComponentSessions(cwd = process.cwd()) { + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('__')) continue; + try { + fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + } catch { /* non-fatal */ } + } +} + +export function deferredAcceptsPath(cwd = process.cwd()) { + const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16); + return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json'); +} + +export function readDeferredAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch { + return { accepts: [] }; + } +} + +export function writeDeferredAccept(entry, cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const data = readDeferredAccepts(cwd); + data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id); + data.accepts.push({ ...entry, createdAt: new Date().toISOString() }); + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + const data = readDeferredAccepts(cwd); + const pending = Array.isArray(data.accepts) ? data.accepts : []; + const results = []; + const remaining = []; + for (const entry of pending) { + try { + const manifest = findSvelteComponentManifest(entry.id, cwd); + if (!manifest) { + results.push({ id: entry.id, ok: false, error: 'manifest not found' }); + remaining.push(entry); + continue; + } + const result = inlineSvelteComponentAccept( + manifest, + entry.variantNum, + entry.paramValues || null, + cwd, + ); + results.push({ id: entry.id, ok: result.handled !== false, result }); + if (result.handled === false) remaining.push(entry); + } catch (err) { + results.push({ id: entry.id, ok: false, error: err.message }); + remaining.push(entry); + } + } + if (remaining.length > 0) { + fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8'); + } else { + try { fs.rmSync(file, { force: true }); } catch {} + } + return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results }; +} + +export function buildSvelteComponentCssAuthoring(count) { + const variantNumbers = Array.from({ length: count }, (_, i) => i + 1); + return { + mode: 'svelte-component', + styleTag: null, + strategy: 'component-style-block', + rulePattern: '.semantic-class { ... }', + selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'), + requirements: [ + 'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).', + 'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.', + 'Put variant CSS in the component ' : '')); + if (paramValues && Object.keys(paramValues).length > 0) { + lines.push( + bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close, + ); + } + lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + lines.push(bodyIndent + '
'); + lines.push(...bodyRestored); + lines.push(bodyIndent + '
'); + }; + + if (isJsx) { + const wrapperStyle = 'style={{ display: "contents" }}'; + lines.push(indent + '
'); + pushCarbonizeBody(indent + ' '); + lines.push(indent + '
'); + } else { + pushCarbonizeBody(indent); + } + + return lines; +} + +function reindentContent(contentLines, fromIndent, toIndent) { + return contentLines.map((line) => { + if (line.trim() === '') return ''; + if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length); + return toIndent + line.trimStart(); + }); +} + function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; @@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const needsCarbonize = !!(cssContent || hasHelperAttrs); - // Build the replacement const restored = deindentContent(variantContent, indent); - const replacement = []; - - if (cssContent) { - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); - // JSX targets need the CSS body wrapped in a template literal so that the - // `{` and `}` in CSS rules don't get parsed as JSX expressions. - replacement.push(indent + '' : '')); - if (paramValues && Object.keys(paramValues).length > 0) { - // Preserve the user's knob positions for the carbonize-cleanup agent - // to bake into the final CSS when it collapses scoped rules. - replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); - } - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); - } - - // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the - // carbonize CSS block working visually by re-wrapping the accepted content - // in a data-impeccable-variant="N" div with `display: contents` (so layout - // isn't affected). The carbonize agent strips this attribute + wrapper when - // it moves the CSS to a proper stylesheet. - // - // Style attribute syntax has to follow the host file's flavor — JSX files - // need the object form, otherwise React 19 throws "Failed to set indexed - // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. - if (cssContent) { - const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; - replacement.push(indent + '
'); - replacement.push(...restored); - replacement.push(indent + '
'); - } else { - replacement.push(...restored); - } + const replacement = buildCarbonizeReplacement({ + indent, + commentSyntax, + isJsx, + id, + variantNum, + cssContent, + paramValues, + restored, + }); const newLines = [ ...lines.slice(0, replaceRange.start), @@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; } +function readSourceShadowPreviewMeta(content, id) { + const escaped = escapeRegExp(id); + const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>'); + const match = String(content || '').match(wrapperRe); + if (!match) return null; + const tag = match[0]; + if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null; + const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file'); + const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start')); + const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end')); + if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null; + return { sourceFile, sourceStartLine, sourceEndLine }; +} + +function readHtmlAttr(tag, name) { + const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1')); + if (!match) return null; + return decodeHtmlAttr(match[2]); +} + +function decodeHtmlAttr(value) { + return String(value || '') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- @@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts }; diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index f609ac847..d7c580e2f 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -26,9 +26,9 @@ return; } - // --------------------------------------------------------------------------- + // // Design tokens - // --------------------------------------------------------------------------- + // // Brand kinpaku (gold) is pinned to the site's neo-kinpaku tokens // (see site/styles/kinpaku-tokens.css) so Accept / knobs / cycle-dots / @@ -115,19 +115,48 @@ { value: 'overdrive', label: 'Overdrive' }, ]; - // --------------------------------------------------------------------------- + const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions']; + const LIVE_UI_SURFACES = [ + { 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'] }, + { key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] }, + { key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-configure-input-wrap', PREFIX + '-input', PREFIX + '-configure-voice'] }, + { key: 'action-picker', ids: [PREFIX + '-picker'] }, + { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, + { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, + { key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] }, + { key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] }, + { key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] }, + { 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'] }, + { key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] }, + { key: 'design-system-panel', ids: [PREFIX + '-design-host'] }, + { key: 'toasts-and-errors', ids: [PREFIX + '-toast'] }, + { key: 'css-isolation-boundary', ids: [PREFIX + '-root'] }, + ]; + const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))]; + + // // State - // --------------------------------------------------------------------------- + // let state = 'IDLE'; let hoveredElement = null; let selectedElement = null; let currentSessionId = null; - let pendingAcceptedSession = null; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; + let svelteComponentSession = null; + let svelteRuntimePromise = null; + let pendingSvelteComponentRetryObserver = null; + let currentSourceFile = null; + let currentPreviewFile = null; + let currentPreviewMode = null; + let recoveryWaitingForAnchor = false; + let pendingAcceptedSession = null; let variantObserver = null; + let variantSelectionInFlight = false; + let variantSelectionPromise = null; + let recoveringEmptyCycling = false; let hasProjectContext = false; let selectedAction = 'impeccable'; let selectedCount = 3; @@ -175,14 +204,17 @@ let highlightEl = null; let tooltipEl = null; let barEl = null; + let barHideSeq = 0; let pickerEl = null; let toastEl = null; let scrollRaf = null; let editBadgeEl = null; + let editBadgeProxyRoot = null; + let editBadgeProxyByTarget = new Map(); - // --------------------------------------------------------------------------- + // // Helpers - // --------------------------------------------------------------------------- + // function own(el) { return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); @@ -204,8 +236,105 @@ return s; } + function rectIsUsableAnchor(rect) { + return !!rect && rect.width > 0.5 && rect.height > 0.5; + } + + function makeFrozenAnchor(el) { + if (!el || !el.getBoundingClientRect) return null; + const r = el.getBoundingClientRect(); + if (!rectIsUsableAnchor(r)) return null; + const rect = { + x: r.x, y: r.y, + top: r.top, left: r.left, + right: r.right, bottom: r.bottom, + width: r.width, height: r.height, + }; + return { + __impeccableFrozenAnchor: true, + tagName: el.tagName || 'DIV', + id: el.id || '', + classList: el.classList ? [...el.classList] : [], + hasAttribute: () => false, + getBoundingClientRect: () => rect, + }; + } + function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } + function cssId(id) { + if (window.CSS?.escape) return CSS.escape(id); + return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); + } + + function liveUiRoot() { + const root = window.__IMPECCABLE_LIVE_UI_ROOT__; + if (root && typeof root.appendChild === 'function') return root; + return document.body; + } + + function uiAppend(el) { + liveUiRoot().appendChild(el); + return el; + } + + function uiAppendStyle(styleEl) { + const root = liveUiRoot(); + if (root && root !== document.body) root.appendChild(styleEl); + else document.head.appendChild(styleEl); + return styleEl; + } + + function uiGetById(id) { + const root = liveUiRoot(); + if (root?.getElementById) { + const found = root.getElementById(id); + if (found) return found; + } + if (root?.querySelector) { + const found = root.querySelector('#' + cssId(id)); + if (found) return found; + } + return document.getElementById(id); + } + + function activeElementDeep() { + let active = document.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; + return active; + } + + window.__IMPECCABLE_LIVE_CHROME_CORE__ = { + version: 1, + adapter: window.__IMPECCABLE_LIVE_ADAPTER__ || 'dom', + mountContract: LIVE_CHROME_MOUNT_CONTRACT, + surfaces: LIVE_UI_SURFACES, + componentIds: LIVE_UI_COMPONENT_IDS, + root: liveUiRoot, + append: uiAppend, + appendStyle: uiAppendStyle, + getById: uiGetById, + activeElementDeep, + debugState: () => ({ + state, + currentSessionId, + expectedVariants, + arrivedVariants, + visibleVariant, + savedSession: loadSession(), + sourceFile: currentSourceFile, + previewFile: currentPreviewFile, + previewMode: currentPreviewMode, + barText: barEl?.textContent || null, + barConnected: !!barEl?.isConnected, + hasSvelteComponentSession: !!svelteComponentSession, + mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0, + pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver, + recoveryWaitingForAnchor, + evtSourceReadyState: evtSource ? evtSource.readyState : null, + }), + }; + // Modal-aware chrome: keep our floating UI clickable inside Radix / // Headless UI / vaul portals. // @@ -245,9 +374,9 @@ rootEl.addEventListener('focusin', stop); } - // --------------------------------------------------------------------------- + // // Highlight overlay - // --------------------------------------------------------------------------- + // function initHighlight() { highlightEl = document.createElement('div'); @@ -259,7 +388,7 @@ transition: HIGHLIGHT_TRANSITION, display: 'none', opacity: '0', }); - document.body.appendChild(highlightEl); + uiAppend(highlightEl); tooltipEl = document.createElement('div'); tooltipEl.id = PREFIX + '-tooltip'; @@ -273,7 +402,7 @@ letterSpacing: '0.02em', transition: TOOLTIP_TRANSITION, }); - document.body.appendChild(tooltipEl); + uiAppend(tooltipEl); } function showHighlight(el) { @@ -310,7 +439,7 @@ if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; } } - // --------------------------------------------------------------------------- + // // Annotation overlay (comment pins + kinpaku strokes) // // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned @@ -318,7 +447,7 @@ // drag) drops a comment pin; drag paints a kinpaku SVG stroke. All coords // are stored in element-local CSS px so they survive scroll / resize and // correlate directly with the captured PNG. - // --------------------------------------------------------------------------- + // const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it @@ -397,7 +526,7 @@ annotOverlayEl.addEventListener('pointermove', onAnnotMove); annotOverlayEl.addEventListener('pointerup', onAnnotUp); annotOverlayEl.addEventListener('pointercancel', onAnnotUp); - document.body.appendChild(annotOverlayEl); + uiAppend(annotOverlayEl); // Modal-host friendliness: pointer-events is already 'auto' on this // overlay; we only need to silence the host's outside-interaction // listeners. Don't override pointer-events here (the overlay toggles @@ -828,9 +957,9 @@ return wrap; } - // --------------------------------------------------------------------------- + // // Element context extraction - // --------------------------------------------------------------------------- + // function stripManualEditRuntimeState(root) { if (!root || root.nodeType !== 1) return; @@ -971,9 +1100,9 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } - // --------------------------------------------------------------------------- + // // The Bar - one floating element, three modes - // --------------------------------------------------------------------------- + // // Contextual-bar palette. Cached at init so every build*Row reads a // consistent set of colors; detectPageTheme runs once rather than on every @@ -1006,7 +1135,7 @@ padding: '6px', maxWidth: '520px', minWidth: '320px', }); - document.body.appendChild(barEl); + uiAppend(barEl); defangOutsideHandlers(barEl); } @@ -1041,6 +1170,8 @@ } function showBar(mode) { + barHideSeq += 1; + if (mode === 'cycling' && !ensureCyclingRenderable('show-bar')) return; barEl.innerHTML = ''; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); @@ -1058,11 +1189,12 @@ function hideBar() { if (!barEl) return; + const hideSeq = ++barHideSeq; stopVoice({ suppressSubmit: true }); if (configureKind === 'insert') clearInsertPicking(); barEl.style.opacity = '0'; barEl.style.transform = 'translateY(6px)'; - setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); + setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); if (state === 'EDITING') restoreInlineEditDrafts(); @@ -1071,6 +1203,7 @@ function updateBarContent(mode) { if (!barEl || barEl.style.display === 'none') return; + if (mode === 'cycling' && !ensureCyclingRenderable('update-bar')) return; barEl.innerHTML = ''; // Reset bar styling to the kinpaku picker palette barEl.style.background = BP.surface; @@ -1090,13 +1223,13 @@ syncPageChatFocus('update-bar-content'); } - // --- Configure row --- + // Configure row function syncConfigureInputChrome() { - const wrap = document.getElementById(PREFIX + '-configure-input-wrap'); - const input = document.getElementById(PREFIX + '-input'); + const wrap = uiGetById(PREFIX + '-configure-input-wrap'); + const input = uiGetById(PREFIX + '-input'); if (!wrap || !input) return; - const focused = document.activeElement === input; + const focused = activeElementDeep() === input; wrap.dataset.inputFocused = focused ? 'true' : 'false'; wrap.dataset.voiceListening = (voiceListening && voiceCtx?.mode === 'configure') ? 'true' : 'false'; wrap.style.borderColor = (voiceListening && voiceCtx?.mode === 'configure') @@ -1104,7 +1237,7 @@ : (focused ? BP.accentSoft : BP.hairline); } - // --- Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) --- + // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) function detectInsertAxisFromStyle(style) { const display = style?.display || 'block'; @@ -1377,7 +1510,7 @@ display: 'none', opacity: '0.9', }); - document.body.appendChild(insertLineEl); + uiAppend(insertLineEl); defangOutsideHandlers(insertLineEl); return insertLineEl; } @@ -1440,6 +1573,10 @@ /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { + if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor) return anchor; + } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (wrapper) { @@ -1557,6 +1694,11 @@ positionBar(); } + function showOrUpdateCyclingBar() { + if (barEl && barEl.style.display !== 'none') updateBarContent('cycling'); + else showBar('cycling'); + } + function buildPlaceholderResizeHandles() { if (!placeholderResizeLayerEl) return; placeholderResizeLayerEl.innerHTML = ''; @@ -1665,7 +1807,7 @@ } function isInsertCreateEnabled(btn) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); + btn = btn || uiGetById(PREFIX + '-insert-create'); return !!btn && btn.getAttribute('aria-disabled') !== 'true'; } @@ -1691,7 +1833,7 @@ lineHeight: '1.35', }); insertCreateTooltipEl.id = PREFIX + '-insert-create-tooltip'; - document.body.appendChild(insertCreateTooltipEl); + uiAppend(insertCreateTooltipEl); return insertCreateTooltipEl; } @@ -1723,8 +1865,8 @@ } function syncInsertCreateButton(btn, input) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); - input = input || document.getElementById(PREFIX + '-insert-input'); + btn = btn || uiGetById(PREFIX + '-insert-create'); + input = input || uiGetById(PREFIX + '-insert-input'); if (!btn || !input) return; const gate = insertCreateGateState(input); const ok = canCreateInsert(gate); @@ -1833,7 +1975,7 @@ voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!document.getElementById(PREFIX + '-configure-input-style')) { + if (!uiGetById(PREFIX + '-configure-input-style')) { const s = document.createElement('style'); s.id = PREFIX + '-configure-input-style'; s.textContent = @@ -1842,7 +1984,7 @@ '#' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: impeccable-configure-voice-pulse 1.1s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-configure-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } input.addEventListener('focus', () => syncConfigureInputChrome()); @@ -1949,6 +2091,9 @@ transition: 'border-color 0.15s ease', }); inputWrap.id = PREFIX + '-insert-input-wrap'; + inputWrap.addEventListener('pointerdown', (e) => e.stopPropagation()); + inputWrap.addEventListener('mousedown', (e) => e.stopPropagation()); + inputWrap.addEventListener('click', (e) => e.stopPropagation()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; @@ -1984,6 +2129,12 @@ voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; input.addEventListener('input', () => syncInsertCreateButton()); + input.addEventListener('pointerdown', (e) => e.stopPropagation()); + input.addEventListener('mousedown', (e) => e.stopPropagation()); + input.addEventListener('click', (e) => { + e.stopPropagation(); + try { input.focus({ preventScroll: true }); } catch { input.focus(); } + }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); @@ -2049,6 +2200,7 @@ }); create.addEventListener('mouseleave', hideInsertCreateTooltip); create.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); if (controlsLocked) { showManualApplyBusyToast(); return; } if (!isInsertCreateEnabled(create)) return; @@ -2060,7 +2212,7 @@ return row; } - // --- Generating row --- + // Generating row function buildGeneratingRow() { const row = el('div', { @@ -2086,19 +2238,24 @@ }); // Variants currently arrive atomically in a single file edit, so a // per-variant counter would lie. Say what's true. - status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + expectedVariants + ' variants...' - : 'Done'; + status.textContent = recoveryWaitingForAnchor + ? 'Variants ready. Reveal the selected element to resume.' + : (arrivedVariants < expectedVariants + ? 'Generating ' + expectedVariants + ' variants...' + : 'Done'); row.appendChild(status); return row; } - // --- Cycling row --- + // Cycling row const TUNE_ICON_SVG = ''; function buildCyclingRow() { + if (!ensureCyclingRenderable('build-cycling-row')) { + return el('div', { display: 'none' }); + } const row = el('div', { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 2px', @@ -2106,6 +2263,7 @@ // Prev const prev = navBtn('\u2190'); + prev.id = PREFIX + '-variant-prev'; prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); }); if (visibleVariant <= 1) prev.style.opacity = '0.3'; row.appendChild(prev); @@ -2118,11 +2276,13 @@ fontFamily: MONO, fontSize: '11px', fontWeight: '500', color: BP.textDim, minWidth: '24px', textAlign: 'center', }); + counter.id = PREFIX + '-variant-counter'; counter.textContent = visibleVariant + '/' + arrivedVariants; row.appendChild(counter); // Next const next = navBtn('\u2192'); + next.id = PREFIX + '-variant-next'; next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); }); if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3'; row.appendChild(next); @@ -2208,9 +2368,9 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders - // --- Saving row (waiting for agent to process accept/discard) --- + // Saving row (waiting for agent to process accept/discard) function buildSavingRow() { const row = el('div', { @@ -2235,7 +2395,7 @@ return row; } - // --- Confirmed row (green success, auto-dismisses) --- + // Confirmed row (green success, auto-dismisses) function buildConfirmedRow() { const row = el('div', { @@ -2256,7 +2416,7 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders function buildDots(clickable) { const container = el('div', { @@ -2290,10 +2450,7 @@ const idx = i; dot.addEventListener('click', (e) => { e.stopPropagation(); - visibleVariant = idx; - showVariantInDOM(currentSessionId, idx); - updateSelectedElement(); - updateBarContent('cycling'); + selectVariant(idx, 'variant_changed'); }); } container.appendChild(dot); @@ -2323,13 +2480,14 @@ function el(tag, styles) { const e = document.createElement(tag); + if (String(tag).toLowerCase() === 'button') e.type = 'button'; if (styles) Object.assign(e.style, styles); return e; } - // --------------------------------------------------------------------------- + // // Action picker popover - // --------------------------------------------------------------------------- + // function initActionPicker() { const P = barPaletteForTheme(detectPageTheme()); @@ -2384,16 +2542,20 @@ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent'; }); chip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + const prompt = uiGetById(PREFIX + '-input')?.value || ''; selectedAction = action.value; hideActionPicker(); updateBarContent('configure'); + const input = uiGetById(PREFIX + '-input'); + if (input && prompt) input.value = prompt; }); grid.appendChild(chip); }); pickerEl.appendChild(grid); - document.body.appendChild(pickerEl); + uiAppend(pickerEl); defangOutsideHandlers(pickerEl); // Cache the palette on the picker so toggleActionPicker's state refresh @@ -2433,7 +2595,33 @@ setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180); } - // --------------------------------------------------------------------------- + function ensureCyclingRenderable(reason) { + if (arrivedVariants > 0) { + if (visibleVariant < 1 || visibleVariant > arrivedVariants) visibleVariant = 1; + return true; + } + recoverEmptyCycling(reason); + return false; + } + + function recoverEmptyCycling(reason) { + if (recoveringEmptyCycling) return; + recoveringEmptyCycling = true; + try { + console.warn('[impeccable] Refusing to render empty variant cycling state:', reason); + const message = 'No variants were mounted. Please try again.'; + if (svelteComponentSession?.sessionId === currentSessionId) { + abortSvelteComponentInjection(currentSessionId, message); + return; + } + cleanup(); + showToast(message, 5000); + } finally { + recoveringEmptyCycling = false; + } + } + + // // Params panel (per-variant coarse controls) // // Variants may declare a parameter manifest via a JSON attribute on the @@ -2446,13 +2634,13 @@ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped // CSS can respond instantly without regeneration: // - // range / numeric toggle → CSS var (`--p-`) used via var(--p-foo, N) + // range / numeric toggle -> CSS custom property used by variant styles // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."] // // On variant switch, values reset to that variant's declared defaults. // On accept, current values are sent in the event payload so the agent // can bake them into the source-file write. - // --------------------------------------------------------------------------- + // let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide) let paramsPanelInner = null; // translating content (carries bg, padding, knobs) @@ -2507,7 +2695,7 @@ }); paramsPanelEl.appendChild(paramsPanelBody); - document.body.appendChild(paramsPanelEl); + uiAppend(paramsPanelEl); // Don't override pointer-events: the panel toggles between 'none' (closed, // click-through) and 'auto' (open) on its own. Just silence the host's // outside-interaction listeners while the panel is open. @@ -2516,14 +2704,40 @@ } + function getMountedSvelteComponentAnchor(session = svelteComponentSession) { + const el = session?.mountTargetEl?.firstElementChild || null; + if (!el || !document.body.contains(el)) return null; + return rectIsUsableAnchor(el.getBoundingClientRect()) ? el : null; + } + + function resolveSvelteComponentAnchor(session = svelteComponentSession) { + return getMountedSvelteComponentAnchor(session) + || session?.swapAnchor + || null; + } + function getVisibleVariantEl() { if (!currentSessionId) return null; + if (svelteComponentSession?.sessionId === currentSessionId) { + return resolveSvelteComponentAnchor() + || svelteComponentSession.wrapperEl + || null; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } function parseVariantParams(variantEl) { + // Svelte component variants can't carry a `data-impeccable-params` attribute: + // the compiler reads `{` inside attribute values as expression delimiters, so + // JSON-with-braces breaks the build. For that path the params live in a sidecar + // params.json keyed by variant number, loaded into the session at mount time. + if (svelteComponentSession?.sessionId === currentSessionId) { + const byVariant = svelteComponentSession.paramsByVariant || {}; + const params = byVariant[String(visibleVariant)] || byVariant[visibleVariant]; + return Array.isArray(params) ? params : []; + } if (!variantEl) return []; const raw = variantEl.getAttribute('data-impeccable-params'); if (!raw) return []; @@ -2685,11 +2899,11 @@ } } - // --------------------------------------------------------------------------- + // // Inline text editing - makes pure-text descendants of the picked element // directly contenteditable. Save stages copy edits in the live buffer; the // Apply copy edits dock later asks the AI to apply the staged batch. - // --------------------------------------------------------------------------- + // let inlineEditRows = []; let inlineEditDrafts = new Map(); @@ -2803,7 +3017,7 @@ function disableInlineEdit(opts = {}) { for (const row of inlineEditRows) { - if (document.activeElement === row.el) row.el.blur(); + if (activeElementDeep() === row.el) row.el.blur(); row.el.removeAttribute('contenteditable'); delete row.el.dataset.impeccableEditable; delete row.el.dataset.impeccableOriginalText; @@ -3133,7 +3347,7 @@ if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) { showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); } else { - showToast('Save failed: retry or cancel', 4000); + showToast('Save failed - retry or cancel', 4000); } } } @@ -3181,11 +3395,11 @@ } function ensureSpinKeyframes() { - if (document.getElementById(PREFIX + '-keyframes')) return; + if (uiGetById(PREFIX + '-keyframes')) return; const style = document.createElement('style'); style.id = PREFIX + '-keyframes'; style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }'; - document.head.appendChild(style); + uiAppendStyle(style); } function pendingApplyLabel(count) { @@ -3318,10 +3532,10 @@ closeTunePopover(); } if (barEl && barEl.style.display !== 'none' && state === 'CONFIGURING') { - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value : ''; updateBarContent('configure'); - const nextInput = document.getElementById(PREFIX + '-input'); + const nextInput = uiGetById(PREFIX + '-input'); if (nextInput) nextInput.value = prompt; } if (editBadgeEl && editBadgeEl.style.display !== 'none') { @@ -3455,19 +3669,19 @@ updatePendingCounter(remaining); if (result.failed && result.failed.length > 0) { console.warn('[impeccable] some copy edits failed:', result.failed); - showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed - see console', 5000); } else { const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0); if (n > 0) { showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); } else { console.warn('[impeccable] apply returned no verified edits:', result); - showToast('No edits applied, see console', 4000); + showToast('No edits applied - see console', 4000); } } } catch (err) { console.error('[impeccable] commit failed:', err); - showToast('Apply failed, see console', 4000); + showToast('Apply failed - see console', 4000); } finally { if (waitForSseCompletion) return; const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0; @@ -3497,7 +3711,7 @@ } } catch (err) { console.error('[impeccable] discard failed:', err); - showToast('Discard failed, see console', 4000); + showToast('Discard failed - see console', 4000); } } @@ -3645,7 +3859,7 @@ const failedCount = numberOrNull(msg.failedCount) || 0; const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0; if (failedCount > 0) { - showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000); + showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed - see console', 5000); } else if (appliedCount > 0) { showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500); } @@ -3799,9 +4013,164 @@ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); } - // --------------------------------------------------------------------------- + // // Edit content badge - floating button at element top-right to enter EDITING mode - // --------------------------------------------------------------------------- + // + + function usesShadowChromeRoot() { + const root = liveUiRoot(); + return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; + } + + function setImportantStyle(el, name, value) { + el.style.setProperty(name, value, 'important'); + } + + function initEditBadgeHitProxies() { + if (!usesShadowChromeRoot() || editBadgeProxyRoot) return; + editBadgeProxyRoot = document.createElement('div'); + editBadgeProxyRoot.id = PREFIX + '-edit-badge-hit-proxies'; + editBadgeProxyRoot.setAttribute('aria-hidden', 'true'); + const styles = { + all: 'initial', + position: 'fixed', + inset: '0', + width: '100vw', + height: '100vh', + zIndex: String(Z.toast + 1), + pointerEvents: 'none', + background: 'transparent', + overflow: 'visible', + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + document.body.appendChild(editBadgeProxyRoot); + } + + function styleEditBadgeProxy(proxy, target) { + const rect = target.getBoundingClientRect(); + const cursor = getComputedStyle(target).cursor || 'pointer'; + const styles = { + all: 'initial', + position: 'fixed', + left: rect.left + 'px', + top: rect.top + 'px', + width: rect.width + 'px', + height: rect.height + 'px', + margin: '0', + padding: '0', + border: '0', + borderRadius: '0', + background: 'transparent', + color: 'transparent', + opacity: '0.001', + pointerEvents: 'auto', + cursor, + zIndex: String(Z.toast + 2), + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(proxy, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + } + + function proxyMouseEvent(type, source, target) { + let event; + try { + event = new MouseEvent(type, { + bubbles: type !== 'mouseenter' && type !== 'mouseleave', + cancelable: true, + composed: true, + clientX: source.clientX, + clientY: source.clientY, + screenX: source.screenX, + screenY: source.screenY, + button: source.button || 0, + buttons: source.buttons || 0, + ctrlKey: source.ctrlKey, + metaKey: source.metaKey, + shiftKey: source.shiftKey, + altKey: source.altKey, + }); + target.dispatchEvent(event); + } catch {} + } + + function bindEditBadgeProxy(proxy, target) { + const stop = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + proxy.addEventListener('mouseenter', (event) => { + stop(event); + proxyMouseEvent('mouseenter', event, target); + proxyMouseEvent('mouseover', event, target); + }); + proxy.addEventListener('mouseleave', (event) => { + stop(event); + proxyMouseEvent('mouseleave', event, target); + proxyMouseEvent('mouseout', event, target); + }); + proxy.addEventListener('mousedown', (event) => { + stop(event); + target.focus?.({ preventScroll: true }); + proxyMouseEvent('mousedown', event, target); + }); + proxy.addEventListener('mouseup', (event) => { + stop(event); + proxyMouseEvent('mouseup', event, target); + }); + proxy.addEventListener('click', (event) => { + stop(event); + target.click(); + syncEditBadgeHitProxies(); + }); + } + + function editBadgeProxyTargets() { + if (!usesShadowChromeRoot() || !editBadgeEl || editBadgeEl.style.display === 'none') return []; + return [...editBadgeEl.querySelectorAll('button')].filter((target) => { + if (target.disabled) return false; + const rect = target.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return false; + const style = getComputedStyle(target); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); + } + + function syncEditBadgeHitProxies() { + if (!usesShadowChromeRoot()) { + if (editBadgeProxyRoot) editBadgeProxyRoot.remove(); + editBadgeProxyRoot = null; + editBadgeProxyByTarget = new Map(); + return; + } + initEditBadgeHitProxies(); + if (!editBadgeProxyRoot) return; + const targets = editBadgeProxyTargets(); + const active = new Set(targets); + for (const [target, proxy] of editBadgeProxyByTarget) { + if (!active.has(target) || !target.isConnected) { + proxy.remove(); + editBadgeProxyByTarget.delete(target); + } + } + for (const target of targets) { + let proxy = editBadgeProxyByTarget.get(target); + if (!proxy) { + proxy = document.createElement('button'); + proxy.type = 'button'; + proxy.tabIndex = -1; + proxy.dataset.impeccableEditBadgeProxy = 'true'; + proxy.setAttribute('aria-hidden', 'true'); + bindEditBadgeProxy(proxy, target); + editBadgeProxyRoot.appendChild(proxy); + editBadgeProxyByTarget.set(target, proxy); + } + proxy.title = target.title || target.textContent || 'Edit copy'; + styleEditBadgeProxy(proxy, target); + } + } function initEditBadge() { editBadgeEl = document.createElement('div'); @@ -3813,10 +4182,11 @@ display: 'none', userSelect: 'none', }); - document.body.appendChild(editBadgeEl); + uiAppend(editBadgeEl); + initEditBadgeHitProxies(); // Remove focus rings on edit badge buttons + contenteditable elements - if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + if (!uiGetById(PREFIX + '-edit-badge-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-edit-badge-focus-style'; s.textContent = @@ -3826,21 +4196,26 @@ '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; - document.head.appendChild(s); + uiAppendStyle(s); } } function positionEditBadge() { - if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') { + syncEditBadgeHitProxies(); + return; + } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { if (editBadgeEl) editBadgeEl.style.display = 'none'; + syncEditBadgeHitProxies(); return; } editBadgeEl.style.display = 'flex'; @@ -4047,7 +4422,7 @@ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN; } // Re-render the bar so the Tune chip picks up the active styling. - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } function closeTunePopover() { @@ -4055,13 +4430,13 @@ hideParamsPanel(); if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT; if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') { - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } } - // --------------------------------------------------------------------------- + // // Variant cycling in DOM - // --------------------------------------------------------------------------- + // function isVariantShown(el) { if (!el) return false; @@ -4081,9 +4456,42 @@ } } - function showVariantInDOM(sessionId, num) { + function scheduleCyclingBarSync(sessionId, variantNum) { + requestAnimationFrame(() => { + if (state !== 'CYCLING') return; + if (currentSessionId !== sessionId) return; + if (visibleVariant !== variantNum) return; + showOrUpdateCyclingBar(); + syncCyclingControls(); + positionBar(); + }); + } + + function syncCyclingControls() { + const shown = svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0 + ? svelteComponentSession.mountedVariant + : visibleVariant; + const counter = uiGetById(PREFIX + '-variant-counter'); + if (counter && arrivedVariants > 0) counter.textContent = shown + '/' + arrivedVariants; + const prev = uiGetById(PREFIX + '-variant-prev'); + const next = uiGetById(PREFIX + '-variant-next'); + if (prev) prev.style.opacity = shown <= 1 ? '0.3' : '1'; + if (next) next.style.opacity = shown >= arrivedVariants ? '0.3' : '1'; + if (currentSessionId && state === 'CYCLING') saveSession(); + } + + async function showVariantInDOM(sessionId, num) { + if (svelteComponentSession?.sessionId === sessionId) { + visibleVariant = num; + const mounted = await mountSvelteComponentVariant(num); + if (!mounted) return false; + updateSelectedElement(); + refreshParamsPanel(); + scheduleCyclingBarSync(sessionId, num); + return true; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); - if (!wrapper) return; + if (!wrapper) return false; for (const child of wrapper.children) { const v = child.dataset ? child.dataset.impeccableVariant : null; if (!v) continue; @@ -4093,6 +4501,378 @@ // CYCLING yet, the subsequent CYCLING transition triggers its own // refresh) and every cycle step. refreshParamsPanel(); + return true; + } + + function isSvelteComponentManifestPath(filePath) { + return String(filePath || '').endsWith('manifest.json'); + } + + function parseOriginalMarkupElement(originalMarkup) { + const parser = new DOMParser(); + const doc = parser.parseFromString('
' + originalMarkup + '
', 'text/html'); + return doc.getElementById('impeccable-anchor')?.firstElementChild || null; + } + + function findLiveElementForOriginalMarkup(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const tag = origContent.tagName.toLowerCase(); + const cls = origContent.className; + let liveEl = null; + if (origContent.id) { + liveEl = document.getElementById(origContent.id); + } else if (cls) { + const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + for (const c of candidates) { + if (c.className === cls && !own(c)) { liveEl = c; break; } + } + if (!liveEl) { + const expectedClasses = String(cls).split(/\s+/).filter(Boolean); + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } + } + } + return liveEl; + } + + function isSvelteInsertManifest(manifest) { + return manifest?.previewMode === 'svelte-component' && manifest?.mode === 'insert'; + } + + function findLiveElementForSvelteManifest(manifest) { + if (isSvelteInsertManifest(manifest)) { + const anchor = findInsertAnchorInDom(); + if (anchor?.parentElement) return anchor; + } + return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function loadSvelteRuntime(runtimeModule) { + const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js'; + const url = new URL(modulePath, location.origin).href; + if (!svelteRuntimePromise) { + svelteRuntimePromise = import(/* @vite-ignore */ url); + } + return svelteRuntimePromise; + } + + // Svelte component variants declare their params in a sidecar params.json under + // componentDir (keyed by variant number), because a `data-impeccable-params` + // attribute with JSON braces can't survive the Svelte compiler. Returns a map of + // { "1": [...params], "2": [...] }; an empty object when the agent declared none. + async function loadSvelteComponentParams(manifest) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir) return {}; + const paramsPath = dir + '/params.json'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(paramsPath); + try { + const res = await fetch(url); + if (!res.ok) return {}; + const parsed = JSON.parse(await res.text()); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out = {}; + for (const [key, value] of Object.entries(parsed)) { + if (Array.isArray(value)) out[String(key)] = value; + } + return out; + } catch { + return {}; + } + } + + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { + const contract = manifest?.propContract || []; + const values = {}; + if (!liveEl || contract.length === 0) return values; + const sourceOriginal = parseOriginalMarkupElement(manifest.originalMarkup || ''); + if (!sourceOriginal) return values; + const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl); + for (const entry of contract) { + const token = '{' + entry.expr + '}'; + values[entry.prop] = map.get(token) || ''; + } + return values; + } + + async function mountSvelteComponentVariant(variantNum) { + if (!svelteComponentSession || !variantNum) return false; + const { manifest, mountTargetEl, sessionId } = svelteComponentSession; + try { + const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement; + svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null; + const runtime = await loadSvelteRuntime(manifest.runtimeModule); + const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte'; + const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now(); + const mod = await import(/* @vite-ignore */ moduleUrl); + const Component = mod.default; + if (svelteComponentSession.mountedInstance && runtime.unmount) { + await runtime.unmount(svelteComponentSession.mountedInstance); + svelteComponentSession.mountedInstance = null; + } + svelteComponentSession.mountedInstance = runtime.mount(Component, { + target: mountTargetEl, + props: { ...svelteComponentSession.propValues }, + intro: false, + }); + svelteComponentSession.mountedVariant = variantNum; + svelteComponentSession.runtime = runtime; + if (state === 'CYCLING') syncCyclingControls(); + const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (nextAnchor) { + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(nextAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = nextAnchor; + } else { + requestAnimationFrame(() => { + if (svelteComponentSession?.sessionId !== sessionId) return; + const settledAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!settledAnchor) return; + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(settledAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = settledAnchor; + }); + } + return true; + } catch (err) { + if (svelteComponentSession?.sessionId === sessionId) { + svelteComponentSession.swapAnchor = null; + } + console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err); + return false; + } + } + + function teardownSvelteComponentSession(restoreOriginal) { + if (!svelteComponentSession) return; + const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + if (restoreOriginal && detachedOriginal && wrapperEl?.parentElement) { + wrapperEl.parentElement.replaceChild(detachedOriginal, wrapperEl); + } else if (wrapperEl?.parentElement) { + wrapperEl.remove(); + } + svelteComponentSession = null; + svelteRuntimePromise = null; + } + + function applyOriginalAttrsToSvelteAnchor(el, originalMarkup) { + if (!el || !originalMarkup) return; + const original = parseOriginalMarkupElement(originalMarkup); + if (!original || original.tagName !== el.tagName) return; + for (const attr of original.attributes) { + if (attr.name === 'class') { + for (const className of attr.value.split(/\s+/).filter(Boolean)) { + el.classList.add(className); + } + } else if (!el.hasAttribute(attr.name)) { + el.setAttribute(attr.name, attr.value); + } + } + } + + function commitAcceptedSvelteComponentToDom(sessionId) { + if (!svelteComponentSession || svelteComponentSession.sessionId !== sessionId) return false; + const { wrapperEl, runtime, mountedInstance, manifest } = svelteComponentSession; + const anchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!anchor || !wrapperEl?.parentElement) return false; + const committed = anchor.cloneNode(true); + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(committed, manifest.originalMarkup || ''); + } + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + wrapperEl.parentElement.replaceChild(committed, wrapperEl); + svelteComponentSession = null; + svelteRuntimePromise = null; + selectedElement = committed; + return true; + } + + async function injectSvelteComponentsFromManifest(manifestPath, sessionId) { + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(manifestPath); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + const manifest = JSON.parse(await res.text()); + if (manifest.id !== sessionId) return; + + const paramsByVariant = await loadSvelteComponentParams(manifest); + currentSessionId = sessionId; + expectedVariants = Number(manifest.count) || expectedVariants || 1; + rememberSessionFileMeta({ + sourceFile: manifest.sourceFile, + previewFile: manifestPath, + previewMode: 'svelte-component', + }); + if (state !== 'CYCLING') state = 'GENERATING'; + + const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { + recoveryWaitingForAnchor = false; + svelteComponentSession.paramsByVariant = paramsByVariant; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; + await mountSvelteComponentVariant(visibleVariant || 1); + state = 'CYCLING'; + showOrUpdateCyclingBar(); + saveSession(); + return; + } + + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) { + console.warn('[impeccable] Could not find original element in live DOM.'); + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants + ? visibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + saveSession(); + queueCheckpoint('svelte_component_anchor_missing'); + waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return; + } + + const wrapper = document.createElement('div'); + wrapper.dataset.impeccableVariants = sessionId; + wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1); + wrapper.dataset.impeccablePreview = 'svelte-component'; + wrapper.style.display = 'contents'; + + const mountTarget = document.createElement('div'); + mountTarget.dataset.impeccableComponentMount = sessionId; + mountTarget.style.display = 'contents'; + wrapper.appendChild(mountTarget); + + const insertMode = isSvelteInsertManifest(manifest); + const detachedOriginal = insertMode ? null : liveEl; + if (insertMode) { + removeInsertPlaceholderDom(); + if (manifest.position === 'before') liveEl.parentElement.insertBefore(wrapper, liveEl); + else liveEl.parentElement.insertBefore(wrapper, liveEl.nextSibling); + } else { + liveEl.parentElement.replaceChild(wrapper, liveEl); + } + + svelteComponentSession = { + sessionId, + manifest, + insertMode, + wrapperEl: wrapper, + mountTargetEl: mountTarget, + detachedOriginal, + mountedInstance: null, + mountedVariant: 0, + runtime: null, + propValues: buildSveltePropValuesFromLiveElement(detachedOriginal, manifest), + paramsByVariant, + }; + if (pendingSvelteComponentRetryObserver) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + } + recoveryWaitingForAnchor = false; + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + + const mounted = await mountSvelteComponentVariant(visibleVariant); + if (!mounted) { + // The compiled component threw (e.g. a Svelte compile error in the + // variant file). Don't strand the bar in an empty CYCLING state; restore + // the original element and reset to PICKING so the user can retry. + abortSvelteComponentInjection(sessionId, 'A variant failed to compile. Fix the component and re-run.'); + return; + } + + selectedElement = mountTarget.firstElementChild || mountTarget; + state = 'CYCLING'; + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.'); + } catch (err) { + console.error('[impeccable] Failed to mount Svelte component variants:', err); + abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.'); + } + } + + function waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }) { + if (pendingSvelteComponentRetryObserver) pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = new MutationObserver(() => { + if (svelteComponentSession?.sessionId === sessionId) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + return; + } + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) return; + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + injectSvelteComponentsFromManifest(manifestPath, sessionId); + }); + pendingSvelteComponentRetryObserver.observe(document.body, { childList: true, subtree: true }); + } + + // Reset cleanly when a Svelte component session can't mount: tear the wrapper + // down (restoring the original element), clear persisted session state, and + // return the bar to PICKING. Avoids the stuck 0/0 CYCLING bar. + function abortSvelteComponentInjection(sessionId, message) { + try { + if (svelteComponentSession?.sessionId === sessionId) { + teardownSvelteComponentSession(true); + } else { + const orphan = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (orphan) orphan.remove(); + } + } catch (err) { + console.warn('[impeccable] Svelte component abort cleanup failed:', err); + } + hideShaderOverlay(); + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + stopScrollLock(); + clearSession(); + clearHandled(); + resetSessionFileMeta(); + currentSessionId = null; + expectedVariants = 0; + arrivedVariants = 0; + visibleVariant = 0; + selectedElement = null; + state = 'PICKING'; + hideBar(); + if (message) showToast(message, 5000); } /** @@ -4101,6 +4881,11 @@ * This works even when the dev server caches HTML (Bun, static servers). */ function injectVariantsFromSource(filePath, sessionId) { + if (isSvelteComponentManifestPath(filePath)) { + injectSvelteComponentsFromManifest(filePath, sessionId); + return; + } + rememberSessionFileMeta({ file: filePath }); const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) @@ -4119,7 +4904,7 @@ const doc = parser.parseFromString(block, 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { - console.error('[impeccable] Variant wrapper not found in source file.'); + console.warn('[impeccable] Variant wrapper not found in source file.'); return; } @@ -4134,31 +4919,31 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; - if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - } - + const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); if (!liveEl) { - console.error('[impeccable] Could not find original element in live DOM.'); + console.warn('[impeccable] Could not find original element in live DOM.'); + selectedElement = document.body; + recoveryWaitingForAnchor = true; + state = 'GENERATING'; + showBar('generating'); + saveSession(); + showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } + recoveryWaitingForAnchor = false; // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); arrivedVariants = variants.length; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + recoverEmptyCycling('source-fallback-empty'); + return; + } const saved = loadSession(); const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants @@ -4170,8 +4955,9 @@ selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4184,21 +4970,129 @@ }); } - function cycleVariant(dir) { + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { + const map = new Map(); + if (!sourceOriginal || !liveOriginal) return map; + + const sourceNodes = collectTextNodes(sourceOriginal) + .filter((node) => /\{[^{}]+\}/.test(node.nodeValue || '')); + const liveTexts = collectTextNodes(liveOriginal) + .map((node) => normalizePreviewText(node.nodeValue || '')) + .filter(Boolean); + let liveIndex = 0; + + for (const sourceNode of sourceNodes) { + const sourceText = sourceNode.nodeValue || ''; + const tokens = sourceText.match(/\{[^{}]+\}/g) || []; + if (tokens.length === 0) continue; + + const liveText = liveTexts[liveIndex++] || ''; + if (!liveText) continue; + + if (tokens.length === 1) { + const token = tokens[0]; + const normalizedSource = normalizePreviewText(sourceText); + if (normalizedSource === token) { + map.set(token, liveText); + continue; + } + + const match = liveText.match(expressionTextMatcher(sourceText, [token])); + if (match && match[1]) map.set(token, match[1].trim()); + continue; + } + + if (normalizePreviewText(sourceText) === tokens.join(' ')) { + for (const token of tokens) { + const tokenLiveText = liveTexts[liveIndex - 1] || ''; + if (tokenLiveText) map.set(token, tokenLiveText); + } + } + } + + return map; + } + + function expressionTextMatcher(sourceText, tokens) { + let pattern = '^'; + let cursor = 0; + for (const token of tokens) { + const index = sourceText.indexOf(token, cursor); + if (index === -1) continue; + pattern += escapeRegExp(sourceText.slice(cursor, index)).replace(/\s+/g, '\\s*'); + pattern += '(.*?)'; + cursor = index + token.length; + } + pattern += escapeRegExp(sourceText.slice(cursor)).replace(/\s+/g, '\\s*') + '$'; + return new RegExp(pattern); + } + + function collectTextNodes(root) { + if (!root) return []; + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + nodes.push(node); + node = walker.nextNode(); + } + return nodes; + } + + function normalizePreviewText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + async function selectVariant(next, checkpointReason) { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - const next = visibleVariant + dir; + if (variantSelectionInFlight) return; if (next < 1 || next > arrivedVariants) return; - visibleVariant = next; - showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself - updateSelectedElement(); - updateBarContent('cycling'); - positionBar(); - saveSession(); - queueCheckpoint('variant_changed'); + if (next === visibleVariant) return; + + const previous = visibleVariant; + variantSelectionInFlight = true; + const selectionPromise = (async () => { + visibleVariant = next; + showOrUpdateCyclingBar(); + saveSession(); + const shown = await showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself + if (!shown) { + visibleVariant = previous; + await showVariantInDOM(currentSessionId, previous); + showOrUpdateCyclingBar(); + saveSession(); + return; + } + updateSelectedElement(); + showOrUpdateCyclingBar(); + positionBar(); + saveSession(); + if (checkpointReason) queueCheckpoint(checkpointReason); + })(); + variantSelectionPromise = selectionPromise; + try { + await selectionPromise; + } finally { + if (variantSelectionPromise === selectionPromise) variantSelectionPromise = null; + variantSelectionInFlight = false; + } + } + + function cycleVariant(dir) { + selectVariant(visibleVariant + dir, 'variant_changed'); } function updateSelectedElement() { if (!currentSessionId) return; + if (svelteComponentSession?.sessionId === currentSessionId) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; + return; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); @@ -4206,6 +5100,9 @@ } function readVisibleVariantFromDOM(sessionId) { + if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { + return svelteComponentSession.mountedVariant; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -4341,9 +5238,9 @@ // scrollY that the next resume needs to read. } - // --------------------------------------------------------------------------- + // // MutationObserver for progressive variant reveal - // --------------------------------------------------------------------------- + // function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -4426,10 +5323,11 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); updateSelectedElement(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4445,9 +5343,9 @@ return obs; } - // --------------------------------------------------------------------------- + // // Bar scroll tracking - // --------------------------------------------------------------------------- + // function startScrollTracking() { function tick() { @@ -4483,10 +5381,10 @@ if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; } } - // --------------------------------------------------------------------------- + // // SSE (server→browser) + fetch POST (browser→server) // Zero-dependency replacement for WebSocket. - // --------------------------------------------------------------------------- + // let evtSource = null; let sseRetries = 0; @@ -4509,6 +5407,7 @@ console.log('[impeccable] Live mode connected.'); syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); + restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; syncPageChatFocus('sse-connected'); break; @@ -4531,11 +5430,12 @@ break; case 'done': if (maybeCompleteSteer(msg)) break; + rememberSessionFileMeta(msg); // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); } @@ -4557,7 +5457,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; if (state !== 'GENERATING') return; showToast( - "Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.", + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", 15000, ); }, 2000); @@ -4571,12 +5471,18 @@ // the final complete event. Keep the browser in its recoverable // saving state while the source cleanup is still in flight. break; + case 'discarded': + if (msg.id && msg.id === currentSessionId) { + markSessionHandled(); + cleanup(); + } + break; case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; state = 'CYCLING'; updateBarContent('cycling'); - showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000); + showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; } if (maybeCompleteSteer(msg)) break; @@ -4656,6 +5562,9 @@ expectedVariants, arrivedVariants, visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, paramValues: { ...paramsCurrentValues }, }; } @@ -4665,6 +5574,20 @@ return sendEvent(checkpointPayload(reason)).catch(() => null); } + function sendSteerCheckpoint(id, reason, extra) { + if (!id) return Promise.resolve(null); + return sendEvent({ + type: 'checkpoint', + id, + revision: sessionState.nextCheckpointRevision(), + owner: browserOwner, + phase: 'steer', + reason, + pageUrl: location.pathname, + ...(extra || {}), + }).catch(() => null); + } + function queueCheckpoint(reason) { if (!currentSessionId) return; if (checkpointTimer) clearTimeout(checkpointTimer); @@ -4674,9 +5597,9 @@ }, 120); } - // --------------------------------------------------------------------------- + // // Event handlers - // --------------------------------------------------------------------------- + // function handleMouseMove(e) { if (pendingApplyInFlight) return; @@ -4860,7 +5783,7 @@ // // DISABLED: quick-Go workflows pay an extra harness round trip because // prefetch + generate arrive as two events instead of one. Re-enable with - // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to + // a browser-side debounce (~800-1000ms, cancelled on Go) if we want to // resurrect this. Server validator and skill dispatch remain in place so // flipping this flag is the only change needed. const PREFETCH_ENABLED = false; @@ -4876,6 +5799,14 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + const deepActive = activeElementDeep(); + if ( + deepActive + && own(deepActive) + && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + ) { + return; + } // While a contenteditable text-leaf is focused, let the browser handle // all keys except Escape. Escape cancels the current edit (restores // original text) and blurs without saving, staying in CONFIGURING. @@ -4982,7 +5913,7 @@ if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!selectedElement || state !== 'CONFIGURING') return; stopVoice({ suppressSubmit: true }); - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; // Commit any pending pin edit BEFORE we snapshot annotations. @@ -4996,6 +5927,7 @@ expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); // Flip to GENERATING immediately so the bar morphs without waiting on // capture + upload. The event is emitted from captureAndEmit() once the @@ -5054,7 +5986,7 @@ function handleInsertCreate() { if (!placeholderElement || !insertAnchorElement || state !== 'CONFIGURING' || configureKind !== 'insert') return; - const input = document.getElementById(PREFIX + '-insert-input'); + const input = uiGetById(PREFIX + '-insert-input'); const prompt = input ? input.value.trim() : ''; if (annotEditing) finalizeEditingPin(); const snapshot = { @@ -5064,10 +5996,12 @@ if (!canCreateInsert({ prompt, comments: snapshot.comments, strokes: snapshot.strokes })) return; stopVoice({ suppressSubmit: true }); + pendingAcceptedSession = null; currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); selectedElement = placeholderElement; insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement); @@ -5107,9 +6041,9 @@ captureAndEmit(elForCapture, basePayload, snapshot, captureRect); } - // --------------------------------------------------------------------------- + // // Screenshot capture + upload - // --------------------------------------------------------------------------- + // let msLoadPromise = null; function loadModernScreenshot() { @@ -5120,7 +6054,7 @@ s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; s.onload = () => resolve(window.modernScreenshot); s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; - document.head.appendChild(s); + uiAppendStyle(s); }); return msLoadPromise; } @@ -5235,11 +6169,113 @@ return '#ffffff'; } + function captureChromeNodes() { + const nodes = []; + const add = (node) => { + if (!node || node === document.body || nodes.includes(node)) return; + nodes.push(node); + }; + add(document.getElementById(PREFIX + '-root')); + [ + PREFIX + '-highlight', + PREFIX + '-tooltip', + PREFIX + '-bar', + PREFIX + '-picker', + PREFIX + '-params-panel', + PREFIX + '-insert-line', + PREFIX + '-insert-placeholder', + PREFIX + '-insert-create-tooltip', + PREFIX + '-annot', + PREFIX + '-design-host', + PREFIX + '-toast', + PREFIX + '-shader', + ].forEach((id) => add(uiGetById(id))); + return nodes; + } + + async function hideCaptureChromeForShaderProxy(fn) { + const saved = captureChromeNodes().map((node) => ({ + node, + visibility: node.style.visibility, + priority: node.style.getPropertyPriority('visibility'), + })); + for (const { node } of saved) { + node.style.setProperty('visibility', 'hidden', 'important'); + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + try { + return await fn(); + } finally { + for (const { node, visibility, priority } of saved) { + node.style.setProperty('visibility', visibility, priority); + } + } + } + + function shouldUseAncestorCropShaderProxy(el) { + // TODO: Enable this proxy for React/Vue/etc. adapters once their live + // preview mounts are covered by the same shader regression checks. + const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase(); + if (adapter === 'svelte' || adapter === 'sveltekit') return true; + if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true; + const wrapper = el?.closest?.('[data-impeccable-variants]'); + return wrapper?.dataset?.impeccablePreview === 'svelte-component'; + } + + function paintsShaderProxySurface(node) { + const s = getComputedStyle(node); + return !isTransparentColor(s.backgroundColor) + || (s.backgroundImage && s.backgroundImage !== 'none') + || paintsBackdrop(node); + } + + function findShaderProxyCaptureRoot(el) { + const doc = el.ownerDocument || document; + const er = el.getBoundingClientRect(); + let node = el.parentElement; + while (node && node !== doc.documentElement) { + const nr = node.getBoundingClientRect(); + const containsElement = + nr.width > 0 && nr.height > 0 && + nr.left <= er.left + 0.5 && + nr.top <= er.top + 0.5 && + nr.right >= er.right - 0.5 && + nr.bottom >= er.bottom - 0.5; + if (containsElement && paintsShaderProxySurface(node)) return node; + node = node.parentElement; + } + return null; + } + // Capture the element (with current annotations baked in) and return // { blob, paper }: the PNG Blob, plus the representative backdrop tone for the // shader's halftone ground (so capture, upload, and shader all agree on what // sits behind the element). Shared between the Go flow (uploads the blob) and // the shader-resume path. + async function captureElementFromRenderedAncestor(ms, el, opts) { + const doc = el.ownerDocument || document; + const captureRoot = findShaderProxyCaptureRoot(el); + if (!captureRoot) throw new Error('No painted ancestor for Svelte shader proxy'); + const rootCanvas = await ms.domToCanvas(captureRoot, opts); + const S = opts.scale; + const er = el.getBoundingClientRect(); + const rr = captureRoot.getBoundingClientRect(); + const sx = (er.left - rr.left) * S; + const sy = (er.top - rr.top) * S; + const sw = er.width * S; + const sh = er.height * S; + if (sw <= 0 || sh <= 0) throw new Error('Selected element has no visible capture rect'); + const crop = doc.createElement('canvas'); + crop.width = Math.max(1, Math.round(sw)); + crop.height = Math.max(1, Math.round(sh)); + const cctx = crop.getContext('2d', { willReadFrequently: true }); + cctx.drawImage(rootCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height); + const paper = dominantRgb01(cctx, crop.width, crop.height) || averageRgb01(cctx, crop.width, crop.height); + const blob = await new Promise((res) => crop.toBlob(res, 'image/png')); + if (!blob) throw new Error('Ancestor crop failed to produce a PNG blob'); + return { blob, paper }; + } + async function captureElementToBlob(el, snapshot, rect) { try { if (document.fonts?.ready) await document.fonts.ready; } catch {} const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); @@ -5261,6 +6297,13 @@ scale: Math.min(window.devicePixelRatio || 1, 2), font: fontCssText ? { cssText: fontCssText } : undefined, }; + if (shouldUseAncestorCropShaderProxy(el)) { + try { + return await hideCaptureChromeForShaderProxy(() => captureElementFromRenderedAncestor(ms, el, opts)); + } catch (err) { + console.warn('[impeccable] Svelte ancestor crop capture failed, falling back to element capture:', err); + } + } const bg = resolveCanvasBackground(el); // Fast path: the element paints its own background, or an opaque ancestor // color was found. modern-screenshot bakes that color; paper matches it. @@ -5342,13 +6385,13 @@ sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); } - // --------------------------------------------------------------------------- + // // Shader overlay - renders the captured screenshot as a WebGL texture and // runs an editorial "ink-wash" fragment shader over it during generation. // A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku // and leaving a soft trail. Makes the wait feel like a letterpress scan // instead of a dead spinner. - // --------------------------------------------------------------------------- + // const SHADER_VS = `attribute vec2 a_position; attribute vec2 a_uv; @@ -5502,6 +6545,31 @@ void main() { return n ? [r / n / 255, g / n / 255, b / n / 255] : SHADER_PAPER_FALLBACK; } + // Pick the most common visible color cluster from a crop. A straight average + // gets pulled by text and icons; the dominant bucket usually represents the + // surface the shader should dissolve into. + function dominantRgb01(ctx, w, h) { + const data = ctx.getImageData(0, 0, w, h).data; + const stride = Math.max(1, Math.floor((w * h) / 6000)); + const buckets = new Map(); + for (let p = 0; p < w * h; p += stride) { + const i = p * 4; + if (data[i + 3] < 16) continue; + const key = (data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4); + const bucket = buckets.get(key) || { count: 0, r: 0, g: 0, b: 0 }; + bucket.count += 1; + bucket.r += data[i]; + bucket.g += data[i + 1]; + bucket.b += data[i + 2]; + buckets.set(key, bucket); + } + let best = null; + for (const bucket of buckets.values()) { + if (!best || bucket.count > best.count) best = bucket; + } + return best ? [best.r / best.count / 255, best.g / best.count / 255, best.b / best.count / 255] : null; + } + // Average the backdrop sampled just OUTSIDE an element's rect within a larger // canvas. The ground tone for the dissolve must be the real backdrop, not the // mean of the element's own crop - averaging the crop folds in the element's @@ -5572,7 +6640,7 @@ void main() { fallback.style.backgroundRepeat = 'no-repeat'; fallback.style.outline = '2px dashed ' + C.brand; fallback.style.outlineOffset = '-2px'; - document.body.appendChild(fallback); + uiAppend(fallback); shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl }; } @@ -5582,16 +6650,19 @@ void main() { const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); + const radius = getComputedStyle(el).borderRadius; canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); Object.assign(canvas.style, { position: 'fixed', top: rect.top + 'px', left: rect.left + 'px', width: rect.width + 'px', height: rect.height + 'px', + borderRadius: radius, + overflow: 'hidden', pointerEvents: 'none', zIndex: Z.bar - 1, }); - document.body.appendChild(canvas); + uiAppend(canvas); const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) || canvas.getContext('experimental-webgl'); @@ -5685,8 +6756,12 @@ void main() { frame(); } - function handleAccept() { + async function handleAccept() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } + if (pendingAcceptedSession || state === 'SAVING') return; + if (variantSelectionPromise) { + try { await variantSelectionPromise; } catch { /* failed selection falls back below */ } + } if (!currentSessionId || arrivedVariants === 0) return; const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId); if (domVisibleVariant > 0) visibleVariant = domVisibleVariant; @@ -5696,30 +6771,39 @@ void main() { variantId: String(visibleVariant), pageUrl: location.pathname, }; + const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } + // The accepted variant is already the only visible child of the wrapper + // (all other variants are display:none). HMR from the source rewrite will + // replace the wrapper imminently. Don't eagerly replaceChild here - React + // reconciliation races with our mutation and throws NotFoundError in Next + // 16 / Turbopack. Schedule a fallback that runs the manual swap only if + // HMR hasn't cleaned up by then (keeps static-server flows working). const acceptedSessionId = currentSessionId; const acceptedVariant = visibleVariant; + const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId + || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - pendingAcceptedSession = { - id: acceptedSessionId, - variant: String(acceptedVariant), - ...acceptedSnapshot, - finalizing: false, - }; state = 'SAVING'; updateBarContent('saving'); + pendingAcceptedSession = { + id: acceptedSessionId, + variant: String(acceptedVariant), + isSvelteComponent: acceptedIsSvelteComponent, + ...acceptedSnapshot, + finalizing: false, + }; + saveSession(); sendEvent(acceptPayload, { throwOnError: true }) - .then(() => { - markSessionHandled(); - }) + .then(() => {}) .catch(() => { - pendingAcceptedSession = null; + if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); } @@ -5733,19 +6817,21 @@ void main() { } if (pending.finalizing) return true; pending.finalizing = true; - + markSessionHandled(); + if (pending.isSvelteComponent) { + commitAcceptedSvelteComponentToDom(pending.id); + } state = 'CONFIRMED'; updateBarContent('confirmed'); + scheduleAcceptCleanup(pending); + return true; + } - // Give framework HMR a short chance to render the now-clean accepted - // source. If it misses the update, unwrap the accepted variant after the - // source-side completion event so the page is not left empty or stale. + function scheduleAcceptCleanup(accepted) { setTimeout(function() { - ensureAcceptedDomClean(pending); + if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted); cleanupAcceptedSession(); }, 1200); - - return true; } function snapshotAcceptedVariantDom(sessionId, variantId) { @@ -5833,6 +6919,7 @@ void main() { stopScrollLock(); clearScrollY(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5841,6 +6928,28 @@ void main() { state = 'PICKING'; } + function commitAcceptedVariantToDom(sessionId, variantId) { + const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapper) return false; + const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); + if (!accepted || !accepted.firstElementChild) return false; + const parent = wrapper.parentElement; + if (!parent) return false; + + const style = wrapper.querySelector('style[data-impeccable-css]'); + if (style && !document.querySelector('style[data-impeccable-accepted-css="' + sessionId + '"]')) { + const promotedStyle = style.cloneNode(true); + promotedStyle.setAttribute('data-impeccable-accepted-css', sessionId); + parent.insertBefore(promotedStyle, wrapper); + } + + const committed = accepted.cloneNode(true); + committed.removeAttribute('hidden'); + committed.style.display = 'contents'; + parent.replaceChild(committed, wrapper); + return true; + } + function handleDiscard() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!currentSessionId) return; @@ -5852,11 +6961,141 @@ void main() { .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000)); } - // --------------------------------------------------------------------------- + // // Session persistence via live-browser-session.js - // --------------------------------------------------------------------------- + // // Survives page reloads, browser close/reopen, HMR, and accidental refreshes. + function normalizeSessionPath(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed.replace(/\\/g, '/') : null; + } + + function resetSessionFileMeta() { + currentSourceFile = null; + currentPreviewFile = null; + currentPreviewMode = null; + recoveryWaitingForAnchor = false; + } + + function rememberSessionFileMeta(meta = {}) { + const file = normalizeSessionPath(meta.file); + const sourceFile = normalizeSessionPath(meta.sourceFile); + const previewFile = normalizeSessionPath(meta.previewFile); + const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null); + + if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) { + currentPreviewMode = 'svelte-component'; + currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile); + currentSourceFile = sourceFile || currentSourceFile; + return; + } + + if (sourceFile || file) currentSourceFile = sourceFile || file; + if (previewFile) currentPreviewFile = previewFile; + if (previewMode) currentPreviewMode = previewMode; + } + + function applySavedSessionMeta(saved) { + if (!saved) return; + rememberSessionFileMeta(saved); + if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.action) selectedAction = saved.action; + if (saved.count) selectedCount = saved.count; + if (saved.previewMode) currentPreviewMode = saved.previewMode; + if (saved.paramValues && typeof saved.paramValues === 'object') { + paramsCurrentValues = { ...saved.paramValues }; + } + } + + function normalizePagePath(value) { + if (!value || typeof value !== 'string') return null; + try { + return new URL(value, location.origin).pathname; + } catch { + return value.split(/[?#]/)[0] || null; + } + } + + function pageMatchesCurrent(value) { + const path = normalizePagePath(value); + return !path || path === location.pathname; + } + + function isTerminalSessionSummary(session) { + return /^(completed|discarded|discard_requested|accept_requested)$/.test(String(session?.phase || '')); + } + + function findActiveSessionSummary(saved, activeSessions) { + if (!saved?.id || !Array.isArray(activeSessions)) return null; + return activeSessions.find((session) => + session?.id === saved.id + && pageMatchesCurrent(session.pageUrl || saved.pageUrl) + && !isTerminalSessionSummary(session) + ) || null; + } + + function clampVariantIndex(value, count) { + const num = Number(value); + const max = Number(count); + if (!Number.isFinite(num) || num < 1) return 0; + if (Number.isFinite(max) && max > 0 && num > max) return 0; + return Math.floor(num); + } + + function restoreSessionWithoutWrapper(reason, activeSessions) { + const saved = loadSession(); + if (!saved?.id || isSessionHandled(saved.id)) return false; + const savedState = String(saved.state || '').toUpperCase(); + if (savedState !== 'GENERATING' && savedState !== 'CYCLING') return false; + + const serverSession = findActiveSessionSummary(saved, activeSessions); + if (Array.isArray(activeSessions) && activeSessions.length > 0 && !serverSession) { + return false; + } + + currentSessionId = saved.id; + applySavedSessionMeta(serverSession); + applySavedSessionMeta(saved); + + expectedVariants = Number(saved.expected || serverSession?.expectedVariants || selectedCount || 0); + arrivedVariants = Number(saved.arrived || serverSession?.arrivedVariants || 0); + if (arrivedVariants <= 0 && currentPreviewFile) arrivedVariants = Number(serverSession?.expectedVariants || saved.expected || selectedCount || 0); + if (expectedVariants <= 0) expectedVariants = Number(serverSession?.expectedVariants || arrivedVariants || selectedCount || 0); + visibleVariant = clampVariantIndex(saved.visible, arrivedVariants || expectedVariants) + || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) + || (arrivedVariants > 0 ? 1 : 0); + + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + if (variantObserver) variantObserver.disconnect(); + variantObserver = startVariantObserver(currentSessionId); + saveSession(); + queueCheckpoint(reason || 'browser_restore_without_wrapper'); + + const restoreFile = currentPreviewMode === 'svelte-component' + ? currentPreviewFile + : (currentSourceFile || currentPreviewFile); + if (restoreFile) { + injectVariantsFromSource(restoreFile, currentSessionId); + return true; + } + + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return true; + } + + function restoreFromActiveSessions(activeSessions, reason) { + const wrapper = document.querySelector('[data-impeccable-variants]'); + if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false; + if (svelteComponentSession?.sessionId === currentSessionId) return false; + return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); + } + function saveSession() { if (!currentSessionId) return; // NOTE: scrollY is stored under a separate key (writeScrollY). Storing @@ -5869,6 +7108,11 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, + pageUrl: location.pathname, + paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, }); } @@ -5898,31 +7142,33 @@ void main() { } function cleanup() { - // Hide the wrapper immediately so variants disappear. DON'T structurally - // mutate the DOM yet - HMR from the agent's source rewrite is on its way, - // and a manual replaceChild under React causes NotFoundError when the - // reconciler later tries to remove a wrapper we already removed. - // Schedule a 2s fallback that does the manual swap only if HMR hasn't - // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const cleanupSessionId = currentSessionId; - if (cleanupSessionId) { + if (svelteComponentSession?.sessionId === cleanupSessionId) { + teardownSvelteComponentSession(true); + } else if (cleanupSessionId) { + // Hide the wrapper immediately so variants disappear. DON'T structurally + // mutate the DOM yet - HMR from the agent's source rewrite is on its way, + // and a manual replaceChild under React causes NotFoundError when the + // reconciler later tries to remove a wrapper we already removed. + // Schedule a 2s fallback that does the manual swap only if HMR hasn't + // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); if (wrapper) wrapper.style.display = 'none'; - } - setTimeout(function() { - if (!cleanupSessionId) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!wrapper) return; - const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - wrapper.parentElement.replaceChild(content, wrapper); - return; + setTimeout(function() { + if (!cleanupSessionId) return; + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) return; + const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); + if (orig) { + const content = orig.firstElementChild; + if (content) { + lateWrapper.parentElement.replaceChild(content, lateWrapper); + return; + } } - } - wrapper.remove(); - }, 2000); + lateWrapper.remove(); + }, 2000); + } hideBar(); hideHighlight(); stopScrollTracking(); @@ -5931,6 +7177,7 @@ void main() { clearScrollY(); finalizeInsertSession(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5938,9 +7185,9 @@ void main() { state = 'PICKING'; } - // --------------------------------------------------------------------------- + // // Toast - // --------------------------------------------------------------------------- + // function showToast(message, duration) { if (toastEl) toastEl.remove(); @@ -5964,7 +7211,7 @@ void main() { }); toastEl.id = PREFIX + '-toast'; toastEl.textContent = message; - document.body.appendChild(toastEl); + uiAppend(toastEl); requestAnimationFrame(() => { toastEl.style.opacity = '1'; toastEl.style.transform = 'translateX(-50%) translateY(0)'; @@ -5978,22 +7225,70 @@ void main() { }, duration); } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. function resumeSession() { const wrapper = document.querySelector('[data-impeccable-variants]'); - if (!wrapper) { clearSession(); clearHandled(); return false; } + if (!wrapper) { + if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } const sessionId = wrapper.dataset.impeccableVariants; // Don't resume if this session was already accepted/discarded if (isSessionHandled(sessionId)) return false; + // Svelte component sessions can't be resumed by counting DOM children: the + // wrapper holds a single mount target, not [data-impeccable-variant] nodes, + // and a page reload unmounts every compiled variant. Counting children here + // would strand the bar in CYCLING at 0/0. If there's no live in-memory mount + // for this wrapper, it's an orphan (reload / failed mount): drop it and let + // the live-server's SSE re-inject the manifest if the session is still live. + if (wrapper.dataset.impeccablePreview === 'svelte-component' + && svelteComponentSession?.sessionId !== sessionId) { + wrapper.remove(); + if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } + + if (wrapper.dataset.impeccablePreview === 'svelte-component') { + if (!svelteComponentSession?.mountedVariant) { + return true; + } + currentSessionId = sessionId; + expectedVariants = Number(wrapper.dataset.impeccableVariantCount) + || Number(svelteComponentSession.manifest?.count) + || expectedVariants + || 1; + arrivedVariants = expectedVariants; + const saved = loadSession(); + applySavedSessionMeta(saved); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = svelteComponentSession.mountedVariant > 0 && svelteComponentSession.mountedVariant <= arrivedVariants + ? svelteComponentSession.mountedVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = resolveSvelteComponentAnchor() + || wrapper.parentElement; + state = 'CYCLING'; + hideShaderOverlay(); + showBar('cycling'); + startScrollTracking(); + refreshParamsPanel(); + saveSession(); + queueCheckpoint('browser_resumed_svelte_component'); + return true; + } + currentSessionId = sessionId; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -6002,6 +7297,7 @@ void main() { // Restore state from localStorage if available const saved = loadSession(); if (saved && saved.id === sessionId) { + applySavedSessionMeta(saved); visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0); if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; @@ -6072,9 +7368,9 @@ void main() { return true; } - // --------------------------------------------------------------------------- + // // Global bar (always visible at bottom) - // --------------------------------------------------------------------------- + // let globalBarEl = null; let globalBarBrandEl = null; @@ -6166,6 +7462,8 @@ void main() { let pageChatExpanded = false; let steerLocked = false; let steerRequestId = null; + let steerPendingMessage = ''; + let steerInputWasFocused = false; let pageChatDotsEl = null; let steerAwaitTimer = null; let voiceRecognition = null; @@ -6179,7 +7477,7 @@ void main() { const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; - const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect'; + const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect'; const GLOBAL_BAR_SECTION_GAP = 8; const GLOBAL_BAR_INNER_GAP = 2; const GLOBAL_BAR_INNER_PAD_LEFT = 2; @@ -6323,7 +7621,7 @@ void main() { const attempt = () => { steerFocusRecoverTimer = null; if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (pageHasHostTextSelection()) { steerFocusRecoverTimer = setTimeout(attempt, 120); return; @@ -6344,7 +7642,7 @@ void main() { steerFocusSuspended = true; steerFocusPauseUntil = performance.now() + STEER_PAGE_FOCUS_PAUSE_MS; pagePointerGesture = { x: e.clientX, y: e.clientY, dragged: false }; - if (pageChatInput && document.activeElement === pageChatInput) { + if (pageChatInput && activeElementDeep() === pageChatInput) { pageChatInput.blur(); } } @@ -6404,7 +7702,7 @@ void main() { pickActive, pageChatReady: !!pageChatInput, pageChatExpanded, - active: steerFocusTargetLabel(document.activeElement), + active: steerFocusTargetLabel(activeElementDeep()), shouldSteer: shouldFocusSteerChat(), ...(extra || {}), }); @@ -6423,26 +7721,26 @@ void main() { function focusConfigureInput(reason) { steerFocusLog('focusConfigureInput', { reason }); const inputId = configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input'; - const input = document.getElementById(inputId); + const input = uiGetById(inputId); if (!input) { steerFocusLog('focusConfigureInput missing', { reason }); return; } setTimeout(() => { - const before = document.activeElement; + const before = activeElementDeep(); input.focus(); steerFocusLog('focusConfigureInput result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== input, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== input, }); }, 60); } function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; - const focused = document.activeElement === pageChatInput; + const focused = activeElementDeep() === pageChatInput; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; const P = pageChatPalette(); pageChatEl.style.borderColor = steerLocked @@ -6476,15 +7774,15 @@ void main() { } syncPageChatVisual(); pageChatInput.style.pointerEvents = 'auto'; - const before = document.activeElement; + const before = activeElementDeep(); try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== pageChatInput, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== pageChatInput, }); } @@ -6515,6 +7813,37 @@ void main() { return wrap; } + function keepSteerPointerInside(e, opts = {}) { + e.stopPropagation(); + if (opts.preventDefault !== false) e.preventDefault(); + } + + function preparePageChatInputForTyping() { + if (!pageChatEl || !pageChatInput) return false; + pageChatExpanded = true; + pageChatEl.dataset.expanded = 'true'; + pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; + pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return true; + } + + function focusPageChatInput(reason) { + if (!preparePageChatInputForTyping() || steerLocked) return false; + try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } + const focused = activeElementDeep() === pageChatInput; + if (focused) steerInputWasFocused = true; + syncPageChatFocusRing(); + return focused; + } + function clearSteerAwaitTimer() { if (steerAwaitTimer) { clearTimeout(steerAwaitTimer); @@ -6528,6 +7857,7 @@ void main() { if (!steerLocked || steerRequestId !== id) return; unlockSteerChat({ error: 'Steer timed out waiting for the agent. Check that live-poll is running and replies with steer_done.', + restoreMessage: steerPendingMessage, }); }, STEER_AWAIT_TIMEOUT_MS); } @@ -6538,19 +7868,12 @@ void main() { steerLocked = true; pageChatEl.dataset.processing = 'true'; pageChatInput.disabled = true; - pageChatInput.value = ''; - pageChatInput.blur(); + preparePageChatInputForTyping(); if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = true; pageChatVoiceBtn.style.display = 'none'; } - pageChatExpanded = false; - pageChatEl.dataset.expanded = 'false'; - pageChatEl.style.width = PAGE_CHAT_PROCESSING_W; pageChatEl.style.cursor = 'default'; - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; pageChatInput.style.pointerEvents = 'none'; if (pageChatHint) { pageChatHint.style.display = 'none'; @@ -6568,17 +7891,26 @@ void main() { function unlockSteerChat(opts) { clearSteerAwaitTimer(); + const restoreMessage = typeof opts?.restoreMessage === 'string' ? opts.restoreMessage : ''; + const keepExpanded = Boolean(opts?.error && restoreMessage); steerLocked = false; + const completedId = steerRequestId; steerRequestId = null; if (!pageChatEl) return; pageChatEl.dataset.processing = 'false'; pageChatEl.removeAttribute('aria-busy'); pageChatEl.setAttribute('aria-label', 'Steer the page'); - pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W; + pageChatExpanded = keepExpanded; + pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false'; + pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W; pageChatEl.style.cursor = 'pointer'; if (pageChatInput) { pageChatInput.disabled = false; - pageChatInput.value = ''; + pageChatInput.value = keepExpanded ? restoreMessage : ''; + pageChatInput.style.width = keepExpanded ? '' : '0'; + pageChatInput.style.padding = keepExpanded ? '0 6px' : '0'; + pageChatInput.style.opacity = keepExpanded ? '1' : '0'; + pageChatInput.style.pointerEvents = 'auto'; } if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = false; @@ -6586,18 +7918,28 @@ void main() { } if (pageChatHint) { pageChatHint.textContent = 'Steer'; - pageChatHint.style.display = ''; - pageChatHint.style.visibility = ''; + pageChatHint.style.display = keepExpanded ? 'none' : ''; + pageChatHint.style.visibility = keepExpanded ? 'hidden' : ''; + pageChatHint.style.opacity = keepExpanded ? '0' : '1'; } if (pageChatDotsEl?.parentNode) { pageChatDotsEl.remove(); pageChatDotsEl = null; } + steerPendingMessage = keepExpanded ? restoreMessage : ''; + steerInputWasFocused = false; syncPageChatChrome(); syncPageChatFocusRing(); if (opts?.error) showToast(String(opts.error), 5000); else if (opts?.message) showToast(String(opts.message), 4000); - syncPageChatFocus('steer-unlock'); + if (completedId) { + sendSteerCheckpoint(completedId, opts?.error ? 'steer_error' : 'steer_done', { + message: opts?.message || opts?.error || '', + file: opts?.file || '', + }); + } + if (keepExpanded) focusPageChatInput('steer-error-restore'); + else syncPageChatFocus('steer-unlock'); } function steerSpeechRecognitionCtor() { @@ -6651,7 +7993,7 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = document.getElementById(PREFIX + '-configure-voice'); + const voiceBtn = uiGetById(PREFIX + '-configure-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -6784,7 +8126,7 @@ void main() { } function configureVoiceContext() { - const input = document.getElementById( + const input = uiGetById( configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input', ); return { @@ -6819,26 +8161,37 @@ void main() { if (!text || steerLocked) return; const id = id8(); steerRequestId = id; + steerPendingMessage = text; + if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); lockSteerChat(); scheduleSteerAwaitTimeout(id); + sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); sendEvent({ type: 'steer', id, message: text, pageUrl: location.href, }).then((res) => { - if (!res) unlockSteerChat({ error: 'Could not reach live server' }); + if (!res) { + sendSteerCheckpoint(id, 'steer_send_failed', { message: text }); + unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text }); + } }); } function maybeCompleteSteer(msg) { if (!steerRequestId || msg.id !== steerRequestId) return false; if (msg.type === 'steer_done') { - unlockSteerChat({ message: msg.message }); + unlockSteerChat({ message: msg.message, file: msg.file }); + if (msg.file && /\.svelte(?:$|\?)/.test(String(msg.file))) { + setTimeout(() => { + if (!steerLocked) showToast('Steer applied. Reload if the page has not refreshed yet.', 5000); + }, 4500); + } return true; } if (msg.type === 'error') { - unlockSteerChat({ error: msg.message || 'Steer failed' }); + unlockSteerChat({ error: msg.message || 'Steer failed', restoreMessage: steerPendingMessage }); return true; } return false; @@ -6847,21 +8200,10 @@ void main() { function expandPageChat(opts) { const focus = !opts || opts.focus !== false; if (!pageChatEl || !pageChatInput || steerLocked) return; - pageChatExpanded = true; - pageChatEl.dataset.expanded = 'true'; - pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; - pageChatEl.style.cursor = 'text'; - if (pageChatHint) { - pageChatHint.style.display = 'none'; - pageChatHint.style.opacity = '0'; - } - pageChatInput.style.width = ''; - pageChatInput.style.padding = '0 6px'; - pageChatInput.style.opacity = '1'; - pageChatInput.style.pointerEvents = 'auto'; + preparePageChatInputForTyping(); syncPageChatChrome(); syncPageChatFocusRing(); - if (focus) pageChatInput.focus(); + if (focus) focusPageChatInput('expand-page-chat'); } function collapsePageChat(opts) { @@ -6878,7 +8220,7 @@ void main() { } else { pageChatInput.style.pointerEvents = 'auto'; } - if (pageChatHint && document.activeElement !== pageChatInput) { + if (pageChatHint && activeElementDeep() !== pageChatInput) { pageChatHint.style.display = ''; pageChatHint.style.opacity = '1'; } @@ -6952,7 +8294,7 @@ void main() { pageChatEl.appendChild(pageChatInput); pageChatEl.appendChild(pageChatVoiceBtn); - if (!document.getElementById(PREFIX + '-page-chat-style')) { + if (!uiGetById(PREFIX + '-page-chat-style')) { const s = document.createElement('style'); s.id = PREFIX + '-page-chat-style'; s.textContent = @@ -6966,23 +8308,34 @@ void main() { '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } - pageChatEl.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { + keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat(); + expandPageChat({ focus: false }); + focusPageChatInput('page-chat-click'); }); - pageChatVoiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); + pageChatVoiceBtn.addEventListener('mousedown', keepSteerPointerInside); pageChatVoiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); + keepSteerPointerInside(e); if (steerLocked) return; toggleSteerVoice(); }); + pageChatInput.addEventListener('pointerdown', keepSteerPointerInside); + pageChatInput.addEventListener('mousedown', keepSteerPointerInside); + pageChatInput.addEventListener('click', (e) => { + keepSteerPointerInside(e); + if (!steerLocked) focusPageChatInput('page-chat-input-click'); + }); + pageChatInput.addEventListener('input', () => { syncPageChatVisual(); }); @@ -6995,7 +8348,7 @@ void main() { syncPageChatFocusRing(); setTimeout(() => { if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (!pageChatInput.value.trim()) collapsePageChat(); scheduleSteerFocusRecover('steer-blur-recover'); }, 120); @@ -7039,7 +8392,7 @@ void main() { globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false'; globalBarBrandEl.setAttribute('aria-label', connected ? 'Impeccable live mode' - : 'Impeccable live mode: agent not polling'); + : 'Impeccable live mode - agent not polling'); globalBarBrandEl.removeAttribute('title'); globalBarBrandEl.style.cursor = connected ? 'default' : 'help'; const mark = globalBarBrandEl.querySelector('[data-brand-mark]'); @@ -7077,7 +8430,7 @@ void main() { }); agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip'; agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP; - document.body.appendChild(agentPollTooltipEl); + uiAppend(agentPollTooltipEl); return agentPollTooltipEl; } @@ -7131,7 +8484,7 @@ void main() { // Custom focus-visible for bar buttons. Browser default is a heavy // blue ring that looks jarring on the dark capsule. Replace with a // soft accent-tinted inner ring that respects the bar's palette. - if (!document.getElementById(PREFIX + '-bar-focus-style')) { + if (!uiGetById(PREFIX + '-bar-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-bar-focus-style'; s.textContent = @@ -7143,7 +8496,7 @@ void main() { '@keyframes impeccable-agent-dot { 0%, 100% { opacity: 0.45; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1); } }' + '#' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: impeccable-agent-dot 1.4s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: none; opacity: 0.9; } }'; - document.head.appendChild(s); + uiAppendStyle(s); } globalBarEl = el('div', { @@ -7176,7 +8529,7 @@ void main() { brand.id = PREFIX + '-global-bar-brand'; brand.dataset.agentConnected = 'false'; brand.setAttribute('role', 'img'); - brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling'); + brand.setAttribute('aria-label', 'Impeccable live mode - agent not polling'); const brandMark = el('span', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -7211,7 +8564,7 @@ void main() { inner.id = PREFIX + '-global-bar-inner'; globalBarEl.appendChild(inner); - // --- button factory: icon-only at rest, label slides in on hover/active --- + // Button factory: icon-only at rest, label slides in on hover/active. function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { const b = el('button', { position: 'relative', @@ -7506,6 +8859,7 @@ void main() { color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', }); + exitBtn.id = PREFIX + '-exit'; exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = 'oklch(58% 0.15 35)'; exitBtn.style.background = P.exitHover; }); @@ -7530,8 +8884,8 @@ void main() { try { window.focus(); } catch { /* in-app preview may block */ } }, true); - document.body.appendChild(pendingDockEl); - document.body.appendChild(globalBarEl); + uiAppend(pendingDockEl); + uiAppend(globalBarEl); defangOutsideHandlers(pendingDockEl); defangOutsideHandlers(globalBarEl); @@ -7553,11 +8907,11 @@ void main() { } function updateGlobalBarState() { - const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); - const detectBadge = document.getElementById(PREFIX + '-detect-badge'); - const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); - const insertToggle = document.getElementById(PREFIX + '-insert-toggle'); - const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const detectToggle = uiGetById(PREFIX + '-detect-toggle'); + const detectBadge = uiGetById(PREFIX + '-detect-badge'); + const pickToggle = uiGetById(PREFIX + '-pick-toggle'); + const insertToggle = uiGetById(PREFIX + '-insert-toggle'); + const designToggle = uiGetById(PREFIX + '-design-toggle'); const theme = globalBarEl?.dataset.theme || 'light'; const P = barPaletteForTheme(theme); @@ -7751,8 +9105,9 @@ void main() { pendingApplyInFlight = false; } if (globalBarEl) { - globalBarEl.style.transform = 'translateY(100%)'; - setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300); + globalBarEl.style.transition = 'none'; + globalBarEl.remove(); + globalBarEl = null; } pageChatEl = null; pageChatInput = null; @@ -7765,6 +9120,7 @@ void main() { if (barEl) { barEl.remove(); barEl = null; } if (pickerEl) { pickerEl.remove(); pickerEl = null; } if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; } + if (editBadgeProxyRoot) { editBadgeProxyRoot.remove(); editBadgeProxyRoot = null; editBadgeProxyByTarget = new Map(); } if (evtSource) { evtSource.close(); evtSource = null; } document.removeEventListener('mousemove', handleMouseMove, true); document.removeEventListener('click', handleClick, true); @@ -7777,9 +9133,9 @@ void main() { console.log('[impeccable] Live mode exited.'); } - // --------------------------------------------------------------------------- + // // Design System Panel - visualizes the project's .impeccable/design.json sidecar - // --------------------------------------------------------------------------- + // const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; const DESIGN_PANEL_WIDTH = 440; @@ -7847,7 +9203,7 @@ void main() { root.className = 'root'; designShadow.appendChild(root); - document.body.appendChild(designHost); + uiAppend(designHost); // The host is pointer-events: none; the panel inside the shadow DOM // manages its own auto/none. Events bubble through the shadow boundary, // so attaching here silences host-page outside-interaction handlers @@ -7889,7 +9245,7 @@ void main() { .root * { box-sizing: border-box; } button { font: inherit; color: inherit; } - /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + /* Panel shell: chrome matches the bar; body canvas stays neutral */ .panel { position: fixed; top: 12px; bottom: 72px; right: 12px; width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); @@ -7955,7 +9311,7 @@ void main() { .panel-body::-webkit-scrollbar { width: 8px; } .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } - /* --- States --- */ + /* States */ .empty, .loading, .error { margin: 16px 4px; padding: 28px 20px; text-align: center; @@ -7966,7 +9322,7 @@ void main() { .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; } .error { color: oklch(45% 0.15 25); } - /* --- Stale hint --- */ + /* Stale hint */ .stale { display: flex; align-items: center; gap: 8px; margin: 8px 4px 12px; @@ -7979,7 +9335,7 @@ void main() { .stale-text { flex: 1; min-width: 0; } .stale-text strong { color: ${DP.ink}; font-weight: 600; } - /* --- Parsed-md fallback banner --- */ + /* Parsed-md fallback banner */ .parsed-md-cta { margin: 8px 4px 14px; padding: 14px 16px; @@ -7991,7 +9347,7 @@ void main() { .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; } .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; } - /* --- Tile primitives --- */ + /* Tile primitives */ .tile { position: relative; background: ${DP.tile}; @@ -8010,7 +9366,7 @@ void main() { } .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; } - /* --- Color tile --- */ + /* Color tile */ .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; } .c-tile:hover { transform: translateY(-1px); } .c-hero { @@ -8025,7 +9381,7 @@ void main() { .c-ramp > span { flex: 1; } .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Type tile --- */ + /* Type tile */ .t-tile { } .t-specimen { margin: 4px 0 6px; @@ -8035,7 +9391,7 @@ void main() { .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; } .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Shadow tile --- */ + /* Shadow tile */ .s-tile { } .s-surface { height: 60px; margin: 8px 2px 10px; @@ -8045,14 +9401,14 @@ void main() { .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; } .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; } - /* --- Radii strip --- */ + /* Radii strip */ .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; } .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; } .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); } .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; } .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; } - /* --- Component tile (hosts live primitives) --- */ + /* Component tile (hosts live primitives) */ .cmp-tile { } .cmp-stage { margin: 12px -4px 0; @@ -8066,7 +9422,7 @@ void main() { .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; } .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; } - /* --- Collapsible --- */ + /* Collapsible */ .coll { margin: 0 4px 8px; background: ${DP.tile}; @@ -8131,7 +9487,7 @@ void main() { .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; } .coll .overview-body li { margin-bottom: 3px; } - /* --- raw tab markdown (unchanged layout, neutralized palette) --- */ + /* raw tab markdown (unchanged layout, neutralized palette) */ .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; } .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; } .md h1 { font-size: 18px; } @@ -8303,7 +9659,7 @@ void main() { return box; } - // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 --- + // Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 function renderDesignVisual(body, parsed, sidecar) { const frontmatter = parsed?.frontmatter || {}; @@ -8667,7 +10023,7 @@ void main() { return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components'); } - // --- Collapsibles --------------------------------------------------------- + // Collapsibles. function buildCollapsible(key, label, count) { const wrap = document.createElement('div'); @@ -8775,7 +10131,7 @@ void main() { return s.replace(/\s+#.*$/, '').trim(); } - // --- Raw tab: minimal markdown renderer (subset) -------------------------- + // Raw tab: minimal markdown renderer (subset) function renderRawTab(body, md) { const wrap = document.createElement('div'); @@ -8908,9 +10264,9 @@ void main() { } catch { /* ignore */ } } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // function init() { try { history.scrollRestoration = 'manual'; } catch {} diff --git a/.pi/skills/impeccable/scripts/live-completion.mjs b/.pi/skills/impeccable/scripts/live-completion.mjs index 86b637fff..986773066 100644 --- a/.pi/skills/impeccable/scripts/live-completion.mjs +++ b/.pi/skills/impeccable/scripts/live-completion.mjs @@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.mode === 'error') return 'error'; + if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error'; return 'agent_done'; } diff --git a/.pi/skills/impeccable/scripts/live-inject.mjs b/.pi/skills/impeccable/scripts/live-inject.mjs index b9d3df41d..3a1f36e46 100644 --- a/.pi/skills/impeccable/scripts/live-inject.mjs +++ b/.pi/skills/impeccable/scripts/live-inject.mjs @@ -17,11 +17,38 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './impeccable-paths.mjs'; +import { + applySvelteKitLiveAdapter, + detectSvelteKitProject, + removeSvelteKitLiveAdapter, +} from './live-sveltekit-adapter.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start'; +const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end'; + +export const LIVE_IGNORE_PATTERNS = Object.freeze([ + '.impeccable/hook.cache.json', + '.impeccable/live/server.json', + '.impeccable/live/sessions/', + '.impeccable/live/previews/', + '.impeccable/live/annotations/', + '.impeccable/live/cache/', + '.impeccable/live/manual-edit-apply-transaction.json', + '.impeccable/live/manual-edit-events.jsonl', + '.impeccable/live/manual-edit-evidence/', + '.impeccable/live/pending-manual-edits.json', + '.impeccable/live/deferred-svelte-component-accepts.json', + '.impeccable-live.json', + '.impeccable-live/', + 'node_modules/.impeccable-live/', + 'src/lib/impeccable/ImpeccableLiveRoot.svelte', + 'src/lib/impeccable/__runtime.js', + 'src/lib/impeccable/[0-9a-f]*/', +]); /** * Hard-excluded directory patterns. These are NEVER user-facing pages and @@ -83,8 +110,14 @@ Output (JSON): validateConfig(config); const resolvedFiles = resolveFiles(process.cwd(), config); + const svelteKit = detectSvelteKitProject(process.cwd(), config); if (args.includes('--remove')) { + if (svelteKit) { + const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config }); + console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; @@ -110,6 +143,13 @@ Output (JSON): console.error(JSON.stringify({ ok: false, error: 'missing_port' })); process.exit(1); } + const gitIgnore = ensureLiveGitIgnores(process.cwd()); + + if (svelteKit) { + const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config }); + console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); @@ -129,10 +169,68 @@ Output (JSON): }; }); const anyInserted = results.some((r) => r.inserted); - console.log(JSON.stringify({ ok: anyInserted, port, results })); + console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results })); if (!anyInserted) process.exit(1); } +export function ensureLiveGitIgnores(cwd = process.cwd()) { + const target = resolveIgnoreTarget(cwd); + const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : ''; + const block = [ + IGNORE_MARKER_OPEN, + ...LIVE_IGNORE_PATTERNS, + IGNORE_MARKER_CLOSE, + ].join('\n'); + const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`); + + let updated; + if (markerRe.test(existing)) { + updated = existing.replace(markerRe, block); + } else { + const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n'; + updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`; + } + + if (updated !== existing) { + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + fs.writeFileSync(target.path, updated, 'utf-8'); + } + + return { + file: path.relative(cwd, target.path).split(path.sep).join('/'), + mode: target.mode, + changed: updated !== existing, + patterns: [...LIVE_IGNORE_PATTERNS], + }; +} + +function resolveIgnoreTarget(cwd) { + const gitExcludePath = resolveGitInfoExcludePath(cwd); + if (gitExcludePath) { + return { path: gitExcludePath, mode: 'git-info-exclude' }; + } + return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' }; +} + +function resolveGitInfoExcludePath(cwd) { + const dotGit = path.join(cwd, '.git'); + if (!fs.existsSync(dotGit)) return null; + + const stat = fs.statSync(dotGit); + if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude'); + if (!stat.isFile()) return null; + + const body = fs.readFileSync(dotGit, 'utf-8').trim(); + const match = body.match(/^gitdir:\s*(.+)$/i); + if (!match) return null; + const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]); + return path.join(gitDir, 'info', 'exclude'); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Expand config.files (which may contain glob patterns) into a literal list * of existing file paths relative to rootDir. Literal entries pass through; diff --git a/.pi/skills/impeccable/scripts/live-insert.mjs b/.pi/skills/impeccable/scripts/live-insert.mjs index 09d4d55be..0658e9914 100644 --- a/.pi/skills/impeccable/scripts/live-insert.mjs +++ b/.pi/skills/impeccable/scripts/live-insert.mjs @@ -21,6 +21,11 @@ import { buildCssAuthoring, buildCssSelectorPrefixExamples, } from './live-wrap.mjs'; +import { + buildSvelteComponentCssAuthoring, + scaffoldSvelteComponentInsertSession, + shouldUseSvelteComponentInjection, +} from './live-svelte-component.mjs'; const INSERT_POSITIONS = new Set(['before', 'after']); @@ -192,6 +197,41 @@ Output (JSON): const styleMode = detectStyleMode(targetFile); const isJsx = commentSyntax.open === '{/*'; const spliceIndex = computeInsertLine(startLine, endLine, position); + const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); + + if (shouldUseSvelteComponentInjection(targetFile)) { + const session = scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile: relTargetFile, + insertLine: spliceIndex + 1, + position, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + anchorLines: lines.slice(startLine, endLine + 1), + cwd: process.cwd(), + }); + console.log(JSON.stringify({ + mode: 'insert', + position, + file: session.manifestFile, + sourceFile: relTargetFile, + previewMode: 'svelte-component', + componentDir: session.componentDir, + propContract: session.propContract, + insertLine: 1, + sourceInsertLine: spliceIndex + 1, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + commentSyntax, + styleMode: 'svelte-component', + styleTag: null, + cssSelectorPrefixExamples: [], + cssAuthoring: buildSvelteComponentCssAuthoring(count), + })); + return; + } + const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? ''; @@ -216,7 +256,7 @@ Output (JSON): console.log(JSON.stringify({ mode: 'insert', position, - file: path.relative(process.cwd(), targetFile), + file: relTargetFile, insertLine: insertLine + 1, commentSyntax, styleMode: styleMode.mode, diff --git a/.pi/skills/impeccable/scripts/live-poll.mjs b/.pi/skills/impeccable/scripts/live-poll.mjs index fad836612..1e1259fbc 100644 --- a/.pi/skills/impeccable/scripts/live-poll.mjs +++ b/.pi/skills/impeccable/scripts/live-poll.mjs @@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs'; // that ceiling and loop in `pollOnce` to synthesize a long poll without // depending on the standalone undici package. export const PER_REQUEST_TIMEOUT_MS = 270_000; +export const DEFAULT_EVENT_LEASE_MS = 600_000; const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); @@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) { ? totalDeadline - Date.now() : PER_REQUEST_TIMEOUT_MS; const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); - const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); + const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`); if (res.status === 401) { const err = new Error('Authentication failed. The server token may have changed.'); @@ -317,7 +318,7 @@ Modes: Options: --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) - --file PATH Attach a source file path to the reply (generate flow) + --file PATH Attach a source file path to the reply (generate/steer flow) --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --help Show this help message diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs index 16c8285b9..cd1091b88 100644 --- a/.pi/skills/impeccable/scripts/live-server.mjs +++ b/.pi/skills/impeccable/scripts/live-server.mjs @@ -42,6 +42,10 @@ import { } from './live-manual-edits-buffer.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs'; +import { + applyDeferredSvelteComponentAccepts, + removeAllSvelteComponentSessions, +} from './live-svelte-component.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated @@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; +const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2; const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); function tombstoneTimedOutApplyId(eventId, details = {}) { @@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) { return entry.event; } entry.leaseUntil = Date.now() + leaseMs; + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return entry.event; } @@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) { const acknowledged = state.pendingEvents[idx].event; state.pendingEvents.splice(idx, 1); scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return acknowledged; } +function findPendingEventById(id) { + if (!id) return null; + const entry = state.pendingEvents.find((item) => item.event?.id === id); + return entry?.event || null; +} + function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; return `live-poll.mjs --reply ${id} done --data ''`; @@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) { return summary; } +function summarizeActiveSessionForClient(snapshot = {}) { + return { + id: snapshot.id, + phase: snapshot.phase, + pageUrl: snapshot.pageUrl ?? null, + sourceFile: snapshot.sourceFile ?? null, + previewFile: snapshot.previewFile ?? null, + previewMode: snapshot.previewMode ?? null, + expectedVariants: snapshot.expectedVariants ?? 0, + arrivedVariants: snapshot.arrivedVariants ?? 0, + visibleVariant: snapshot.visibleVariant ?? null, + checkpointRevision: snapshot.checkpointRevision ?? 0, + paramValues: snapshot.paramValues || {}, + }; +} + +function activeSessionSummaries() { + if (!state.sessionStore) return []; + return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot)); +} + +function cancelQueuedAnonymousExitEvents() { + let removed = 0; + for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) { + const event = state.pendingEvents[i]?.event; + if (event?.type !== 'exit' || event.id) continue; + state.pendingEvents.splice(i, 1); + removed += 1; + } + if (removed > 0) { + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); + } + return removed; +} + function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { const canceledById = new Map(); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); @@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() { clearTimeout(state.leaseTimer); state.leaseTimer = null; } - if (state.pendingPolls.length === 0) return; const now = Date.now(); const nextLeaseUntil = state.pendingEvents .map((entry) => entry.leaseUntil || 0) @@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() { state.leaseTimer = setTimeout(() => { state.leaseTimer = null; flushPendingPolls(); - }, Math.max(0, nextLeaseUntil - now)); + broadcastAgentPollingIfChanged(); + }, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS)); } function flushPendingPolls() { @@ -1032,7 +1082,9 @@ function flushPendingPolls() { } function agentPollingConnected() { - return state.pendingPolls.length > 0; + const now = Date.now(); + return state.pendingPolls.length > 0 + || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now); } function broadcastAgentPollingIfChanged() { @@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/status') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } - const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; + const sessions = activeSessionSummaries(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', @@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/events' && req.method === 'GET') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + clearTimeout(state.exitTimer); + state.exitTimer = null; + cancelQueuedAnonymousExitEvents(); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { type: 'connected', hasProjectContext: hasProjectContext(), agentPolling: agentPollingConnected(), + activeSessions: activeSessionSummaries(), }) + '\n\n'); state.sseClients.add(res); - clearTimeout(state.exitTimer); // Keepalive: SSE comment every 30s prevents silent connection drops. const heartbeat = setInterval(() => { @@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } } + if (msg.type === 'exit') { + cleanupSvelteComponentSessionsBeforeExit(); + } if (msg.type !== 'checkpoint') { enqueueEvent(msg); } @@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) { }); } +function sessionFileMetadataFromPollReply(file) { + if (!file || typeof file !== 'string') return { file }; + const normalized = file.split(path.sep).join('/'); + const base = { file: normalized }; + if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base; + if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base; + + let full; + try { + full = path.resolve(process.cwd(), normalized); + const rel = path.relative(process.cwd(), full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base; + } catch { + return base; + } + + try { + const manifest = JSON.parse(fs.readFileSync(full, 'utf-8')); + if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base; + return { + file: String(manifest.sourceFile).split(path.sep).join('/'), + sourceFile: String(manifest.sourceFile).split(path.sep).join('/'), + previewFile: normalized, + previewMode: 'svelte-component', + }; + } catch { + return base; + } +} + function handlePollPost(req, res) { let body = ''; req.on('data', (c) => { body += c; }); @@ -1965,6 +2053,16 @@ function handlePollPost(req, res) { res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); return; } + const pendingEventBeforeAck = findPendingEventById(msg.id); + if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done' + && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'steer_done_requires_file_or_message', + hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.', + })); + return; + } const acknowledgedEvent = acknowledgePendingEvent(msg.id); let skipJournalReply = false; let existingSession = null; @@ -1987,6 +2085,7 @@ function handlePollPost(req, res) { })); return; } + const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); if (state.sessionStore && msg.id && !skipJournalReply) { try { const eventType = msg.type === 'steer_done' @@ -2001,7 +2100,10 @@ function handlePollPost(req, res) { state.sessionStore.appendEvent({ type: eventType, id: msg.id, - file: msg.file, + file: replyFileMeta.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, message: msg.message, sourceEventType: acknowledgedEvent?.type, carbonize: msg.data?.carbonize === true, @@ -2010,7 +2112,16 @@ function handlePollPost(req, res) { } flushPendingPolls(); // Forward the reply to the browser via SSE - broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + broadcast({ + type: msg.type || 'done', + id: msg.id, + message: msg.message, + file: msg.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, + data: msg.data, + }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); @@ -2023,6 +2134,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { + cleanupSvelteComponentSessionsBeforeExit(); removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; @@ -2037,6 +2149,25 @@ function shutdown() { process.exit(0); } +function cleanupSvelteComponentSessionsBeforeExit() { + try { + removeAllSvelteComponentSessions(process.cwd()); + } catch (err) { + console.warn('[impeccable] Svelte component session cleanup failed:', err.message); + } +} + +function applyLegacyDeferredAcceptsOnStartup() { + try { + const result = applyDeferredSvelteComponentAccepts(process.cwd()); + if (result.applied > 0 || result.failed > 0) { + console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result)); + } + } catch (err) { + console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({ cwd: process.cwd(), reason: 'manual_edit_server_start_recovered_abandoned_transaction', }); +applyLegacyDeferredAcceptsOnStartup(); restorePendingEventsFromStore(); pruneStaleManualApplyEvidence(process.cwd()); const portArg = args.find(a => a.startsWith('--port=')); diff --git a/.pi/skills/impeccable/scripts/live-session-store.mjs b/.pi/skills/impeccable/scripts/live-session-store.mjs index 7562e3d5b..5ec4d34d4 100644 --- a/.pi/skills/impeccable/scripts/live-session-store.mjs +++ b/.pi/skills/impeccable/scripts/live-session-store.mjs @@ -106,6 +106,8 @@ function baseSnapshot(id) { phase: 'new', pageUrl: null, sourceFile: null, + previewFile: null, + previewMode: null, expectedVariants: 0, arrivedVariants: 0, visibleVariant: null, @@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; - next.sourceFile = event.file ?? next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0); next.pendingEventSeq = null; next.pendingEvent = null; if (event.carbonize === true) { @@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { } break; case 'checkpoint': + if (COMPLETED_PHASES.has(next.phase)) { + next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null }); + break; + } if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { next.phase = event.phase ?? next.phase; next.checkpointRevision = event.revision ?? next.checkpointRevision; next.activeOwner = event.owner ?? next.activeOwner; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.visibleVariant = event.visibleVariant ?? next.visibleVariant; + next.sourceFile = event.sourceFile ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; if (event.paramValues) next.paramValues = { ...event.paramValues }; } else { next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); @@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'steer_done': next.phase = 'steer_done'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.message = event.message ?? next.message; next.pendingEventSeq = null; next.pendingEvent = null; break; @@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'complete': next.phase = 'completed'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; next.pendingEventSeq = null; next.pendingEvent = null; break; diff --git a/.pi/skills/impeccable/scripts/live-svelte-component.mjs b/.pi/skills/impeccable/scripts/live-svelte-component.mjs new file mode 100644 index 000000000..dc35dc0ce --- /dev/null +++ b/.pi/skills/impeccable/scripts/live-svelte-component.mjs @@ -0,0 +1,826 @@ +/** + * Svelte live-mode component injection helpers. + * + * Variants are real .svelte components under node_modules/.impeccable-live//. + * The browser mounts them via Svelte 5 mount(); accept inlines the chosen + * variant back into the route source with props mapped to original bindings. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; + +export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live'; +export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`; +export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json'; + +const MUSTACHE_RE = /\{([^{}]+)\}/g; + +export function shouldUseSvelteComponentInjection(filePath) { + if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false; + return path.extname(filePath).toLowerCase() === '.svelte'; +} + +export function componentSessionDir(id, cwd = process.cwd()) { + return path.join(cwd, SVELTE_COMPONENT_ROOT, id); +} + +export function manifestPathForSession(id, cwd = process.cwd()) { + return path.join(componentSessionDir(id, cwd), 'manifest.json'); +} + +export function ensureRuntimeHelper(cwd = process.cwd()) { + const file = path.join(cwd, SVELTE_RUNTIME_FILE); + if (fs.existsSync(file)) return file; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8'); + return file; +} + +/** + * Extract ordered unique mustache expressions from markup (not inside ). + */ +export function extractMustacheExpressions(text) { + const expressions = []; + const seen = new Set(); + const lines = String(text || '').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('\n` + : ''; + return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n\n`; +} + +function buildInsertVariantStub(variantNum) { + return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; +} + +export function scaffoldSvelteComponentSession({ + id, + count, + sourceFile, + sourceStartLine, + sourceEndLine, + originalLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const originalMarkup = originalLines.join('\n'); + const contract = buildPropContract(extractMustacheExpressions(originalMarkup)); + const originalWithProps = substituteExprsWithProps(originalMarkup, contract); + + const manifest = { + id, + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + sourceStartLine, + sourceEndLine, + count, + propContract: contract, + originalMarkup, + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: contract, + }; +} + +export function scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile, + insertLine, + position, + anchorStartLine, + anchorEndLine, + anchorLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const anchorMarkup = (anchorLines || []).join('\n'); + const manifest = { + id, + mode: 'insert', + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + insertLine, + position, + anchorStartLine, + anchorEndLine, + originalMarkup: anchorMarkup, + anchorMarkup, + count, + propContract: [], + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: [], + }; +} + +export function findSvelteComponentManifest(id, cwd = process.cwd()) { + const direct = manifestPathForSession(id, cwd); + if (fs.existsSync(direct)) { + return readManifest(direct); + } + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return null; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(root, entry.name, 'manifest.json'); + if (!fs.existsSync(candidate)) continue; + try { + const manifest = readManifest(candidate); + if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; + } catch { /* skip */ } + } + return null; +} + +export function readManifest(manifestPath) { + const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + return { + ...data, + manifestPath, + }; +} + +export function resolveSourceFile(sourceFile, cwd = process.cwd()) { + if (!sourceFile || path.isAbsolute(sourceFile)) { + throw new Error('Invalid svelte-component source file'); + } + const full = path.resolve(cwd, sourceFile); + const rel = path.relative(cwd, full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('Svelte-component source file escapes project root'); + } + if (!fs.existsSync(full)) { + throw new Error('Svelte-component source file not found: ' + sourceFile); + } + return full; +} + +function appendCssToSvelteStyle(lines, cssLines) { + const closeIdx = findLastStyleCloseLine(lines); + const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; + if (closeIdx === -1) { + return [...lines, '', '']; + } + return [ + ...lines.slice(0, closeIdx), + ...prepared, + ...lines.slice(closeIdx), + ]; +} + +function findLastStyleCloseLine(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + if (/<\/style\s*>/.test(lines[i])) return i; + } + return -1; +} + +function bakeParamValuesInCss(cssLines, paramValues) { + if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; + return cssLines.map((line) => { + let out = line; + for (const [key, value] of Object.entries(paramValues)) { + const varName = `--p-${key}`; + out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); + } + return out; + }); +} + +function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { + const css = String((cssLines || []).join('\n')); + if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; + + const rules = parseCssRules(css); + const output = []; + for (const rule of rules) { + appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); + } + return output.join('\n') + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ''); +} + +function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { + const prelude = rule.prelude.trim(); + const body = rule.body.trim(); + if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; + + if (/^@scope\b/i.test(prelude)) { + if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; + const inner = parseCssRules(body); + for (const innerRule of inner) { + const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); + if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; + output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); + } + return; + } + + const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); + if (!rewrittenPrelude) return; + output.push(formatCssRule(rewrittenPrelude, body)); +} + +function parseCssRules(css) { + const rules = []; + const text = String(css || ''); + let i = 0; + while (i < text.length) { + while (i < text.length && /\s/.test(text[i])) i++; + const preludeStart = i; + while (i < text.length && text[i] !== '{') i++; + if (i >= text.length) break; + const prelude = text.slice(preludeStart, i).trim(); + i++; + const bodyStart = i; + let depth = 1; + let quote = null; + let comment = false; + while (i < text.length && depth > 0) { + const ch = text[i]; + const next = text[i + 1]; + if (comment) { + if (ch === '*' && next === '/') { + comment = false; + i += 2; + continue; + } + i++; + continue; + } + if (quote) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === quote) quote = null; + i++; + continue; + } + if (ch === '/' && next === '*') { + comment = true; + i += 2; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); + if (prelude) rules.push({ prelude, body }); + } + return rules; +} + +function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { + const selectors = splitSelectorList(prelude); + const rewritten = []; + for (const selector of selectors) { + const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); + if (next) rewritten.push(next); + } + return rewritten.join(', '); +} + +function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { + let out = selector.trim(); + const hasVariant = /data-impeccable-variant/.test(out); + if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; + if (hasVariant) { + out = out.replace(variantSelectorRegex(variantNum), ''); + out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); + } + + const paramResult = rewriteParamSelectors(out, paramValues); + if (!paramResult.keep) return ''; + out = paramResult.selector; + + out = out + .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') + .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') + .replace(/\s+/g, ' ') + .trim(); + + out = out.replace(/^[>+~]\s*/, '').trim(); + if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; + return out; +} + +function rewriteParamSelectors(selector, paramValues) { + let keep = true; + const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { + if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; + const actual = paramValues[key]; + if (expected != null && String(actual) !== String(expected)) { + keep = false; + return ''; + } + if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { + keep = false; + return ''; + } + return ''; + }); + return { keep, selector: next }; +} + +function splitSelectorList(prelude) { + const selectors = []; + let start = 0; + let bracket = 0; + let paren = 0; + let quote = null; + for (let i = 0; i < prelude.length; i++) { + const ch = prelude[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '[') bracket++; + else if (ch === ']') bracket = Math.max(0, bracket - 1); + else if (ch === '(') paren++; + else if (ch === ')') paren = Math.max(0, paren - 1); + else if (ch === ',' && bracket === 0 && paren === 0) { + selectors.push(prelude.slice(start, i)); + start = i + 1; + } + } + selectors.push(prelude.slice(start)); + return selectors; +} + +function selectorHasVariant(selector, variantNum) { + return variantSelectorRegex(variantNum).test(selector); +} + +function variantSelectorRegex(variantNum) { + return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); +} + +function formatCssRule(selector, body) { + return `${selector} { ${body.trim()} }`; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { + const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); + const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); + const resultBase = { + file: manifest.sourceFile, + sourceFile: manifest.sourceFile, + previewMode: 'svelte-component', + componentDir: manifest.componentDir, + carbonize: false, + }; + if (!fs.existsSync(variantPath)) { + return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; + } + + const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); + if (manifest.mode === 'insert') { + return inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, + }); + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const contract = manifest.propContract || []; + const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); + const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract) + .split('\n') + .map((line) => line.trimEnd()); + + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const start = Number(manifest.sourceStartLine) - 1; + const end = Number(manifest.sourceEndLine) - 1; + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { + return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; + } + + const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, start), + ...indentedMarkup, + ...sourceLines.slice(end + 1), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, +}) { + if (!svelteMarkupHasVisibleContent(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; + } + if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const restoredMarkup = String(markup || '') + .split('\n') + .map((line) => line.trimEnd()); + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const insertIndex = Number(manifest.insertLine) - 1; + if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { + return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; + } + + const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; + const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, insertIndex), + ...indentedMarkup, + ...sourceLines.slice(insertIndex), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function svelteMarkupHasVisibleContent(markup) { + const text = String(markup || '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 0) return true; + return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); +} + +function mergeOriginalTopLevelAttrs(markup, originalMarkup) { + const variantOpen = matchOpeningTag(markup); + const originalOpen = matchOpeningTag(originalMarkup); + if (!variantOpen || !originalOpen) return markup; + if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; + + const variantAttrs = parseAttrSegments(variantOpen.attrs); + const originalAttrs = parseAttrSegments(originalOpen.attrs); + const additions = []; + let attrs = variantOpen.attrs; + + const originalClass = originalAttrs.get('class'); + const variantClass = variantAttrs.get('class'); + if (originalClass && variantClass) { + const merged = mergeStaticClassAttr(originalClass, variantClass); + if (merged) { + attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); + variantAttrs.set('class', { ...variantClass, raw: merged }); + } + } else if (originalClass && !variantClass) { + additions.push(originalClass.raw); + } + + for (const [name, attr] of originalAttrs) { + if (name === 'class') continue; + if (!variantAttrs.has(name)) additions.push(attr.raw); + } + + if (additions.length === 0 && attrs === variantOpen.attrs) return markup; + const nextOpen = variantOpen.prefix + + variantOpen.tag + + attrs + + additions.map((attr) => ' ' + attr.trim()).join('') + + variantOpen.close; + return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); +} + +function matchOpeningTag(markup) { + const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); + if (!match) return null; + return { + raw: match[0], + prefix: match[1], + tag: match[2], + attrs: match[3] || '', + close: match[4], + index: match.index || 0, + }; +} + +function parseAttrSegments(attrs) { + const out = new Map(); + const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; + let match; + while ((match = re.exec(attrs))) { + const raw = match[0]; + const name = match[1]; + out.set(name, { + name, + raw, + start: match.index, + end: match.index + raw.length, + }); + } + return out; +} + +function mergeStaticClassAttr(originalClass, variantClass) { + const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + if (!originalValue || !variantValue) return null; + const quote = variantValue[1]; + const classes = [ + ...variantValue[2].split(/\s+/), + ...originalValue[2].split(/\s+/), + ].filter(Boolean); + return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; +} + +export function removeSvelteComponentSession(id, cwd = process.cwd()) { + const dir = componentSessionDir(id, cwd); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* non-fatal */ } +} + +export function removeAllSvelteComponentSessions(cwd = process.cwd()) { + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('__')) continue; + try { + fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + } catch { /* non-fatal */ } + } +} + +export function deferredAcceptsPath(cwd = process.cwd()) { + const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16); + return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json'); +} + +export function readDeferredAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch { + return { accepts: [] }; + } +} + +export function writeDeferredAccept(entry, cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const data = readDeferredAccepts(cwd); + data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id); + data.accepts.push({ ...entry, createdAt: new Date().toISOString() }); + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + const data = readDeferredAccepts(cwd); + const pending = Array.isArray(data.accepts) ? data.accepts : []; + const results = []; + const remaining = []; + for (const entry of pending) { + try { + const manifest = findSvelteComponentManifest(entry.id, cwd); + if (!manifest) { + results.push({ id: entry.id, ok: false, error: 'manifest not found' }); + remaining.push(entry); + continue; + } + const result = inlineSvelteComponentAccept( + manifest, + entry.variantNum, + entry.paramValues || null, + cwd, + ); + results.push({ id: entry.id, ok: result.handled !== false, result }); + if (result.handled === false) remaining.push(entry); + } catch (err) { + results.push({ id: entry.id, ok: false, error: err.message }); + remaining.push(entry); + } + } + if (remaining.length > 0) { + fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8'); + } else { + try { fs.rmSync(file, { force: true }); } catch {} + } + return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results }; +} + +export function buildSvelteComponentCssAuthoring(count) { + const variantNumbers = Array.from({ length: count }, (_, i) => i + 1); + return { + mode: 'svelte-component', + styleTag: null, + strategy: 'component-style-block', + rulePattern: '.semantic-class { ... }', + selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'), + requirements: [ + 'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).', + 'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.', + 'Put variant CSS in the component ' : '')); + if (paramValues && Object.keys(paramValues).length > 0) { + lines.push( + bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close, + ); + } + lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + lines.push(bodyIndent + '
'); + lines.push(...bodyRestored); + lines.push(bodyIndent + '
'); + }; + + if (isJsx) { + const wrapperStyle = 'style={{ display: "contents" }}'; + lines.push(indent + '
'); + pushCarbonizeBody(indent + ' '); + lines.push(indent + '
'); + } else { + pushCarbonizeBody(indent); + } + + return lines; +} + +function reindentContent(contentLines, fromIndent, toIndent) { + return contentLines.map((line) => { + if (line.trim() === '') return ''; + if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length); + return toIndent + line.trimStart(); + }); +} + function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; @@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const needsCarbonize = !!(cssContent || hasHelperAttrs); - // Build the replacement const restored = deindentContent(variantContent, indent); - const replacement = []; - - if (cssContent) { - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); - // JSX targets need the CSS body wrapped in a template literal so that the - // `{` and `}` in CSS rules don't get parsed as JSX expressions. - replacement.push(indent + '' : '')); - if (paramValues && Object.keys(paramValues).length > 0) { - // Preserve the user's knob positions for the carbonize-cleanup agent - // to bake into the final CSS when it collapses scoped rules. - replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); - } - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); - } - - // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the - // carbonize CSS block working visually by re-wrapping the accepted content - // in a data-impeccable-variant="N" div with `display: contents` (so layout - // isn't affected). The carbonize agent strips this attribute + wrapper when - // it moves the CSS to a proper stylesheet. - // - // Style attribute syntax has to follow the host file's flavor — JSX files - // need the object form, otherwise React 19 throws "Failed to set indexed - // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. - if (cssContent) { - const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; - replacement.push(indent + '
'); - replacement.push(...restored); - replacement.push(indent + '
'); - } else { - replacement.push(...restored); - } + const replacement = buildCarbonizeReplacement({ + indent, + commentSyntax, + isJsx, + id, + variantNum, + cssContent, + paramValues, + restored, + }); const newLines = [ ...lines.slice(0, replaceRange.start), @@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; } +function readSourceShadowPreviewMeta(content, id) { + const escaped = escapeRegExp(id); + const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>'); + const match = String(content || '').match(wrapperRe); + if (!match) return null; + const tag = match[0]; + if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null; + const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file'); + const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start')); + const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end')); + if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null; + return { sourceFile, sourceStartLine, sourceEndLine }; +} + +function readHtmlAttr(tag, name) { + const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1')); + if (!match) return null; + return decodeHtmlAttr(match[2]); +} + +function decodeHtmlAttr(value) { + return String(value || '') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- @@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts }; diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js index f609ac847..d7c580e2f 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -26,9 +26,9 @@ return; } - // --------------------------------------------------------------------------- + // // Design tokens - // --------------------------------------------------------------------------- + // // Brand kinpaku (gold) is pinned to the site's neo-kinpaku tokens // (see site/styles/kinpaku-tokens.css) so Accept / knobs / cycle-dots / @@ -115,19 +115,48 @@ { value: 'overdrive', label: 'Overdrive' }, ]; - // --------------------------------------------------------------------------- + const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions']; + const LIVE_UI_SURFACES = [ + { 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'] }, + { key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] }, + { key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-configure-input-wrap', PREFIX + '-input', PREFIX + '-configure-voice'] }, + { key: 'action-picker', ids: [PREFIX + '-picker'] }, + { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, + { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, + { key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] }, + { key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] }, + { key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] }, + { 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'] }, + { key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] }, + { key: 'design-system-panel', ids: [PREFIX + '-design-host'] }, + { key: 'toasts-and-errors', ids: [PREFIX + '-toast'] }, + { key: 'css-isolation-boundary', ids: [PREFIX + '-root'] }, + ]; + const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))]; + + // // State - // --------------------------------------------------------------------------- + // let state = 'IDLE'; let hoveredElement = null; let selectedElement = null; let currentSessionId = null; - let pendingAcceptedSession = null; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; + let svelteComponentSession = null; + let svelteRuntimePromise = null; + let pendingSvelteComponentRetryObserver = null; + let currentSourceFile = null; + let currentPreviewFile = null; + let currentPreviewMode = null; + let recoveryWaitingForAnchor = false; + let pendingAcceptedSession = null; let variantObserver = null; + let variantSelectionInFlight = false; + let variantSelectionPromise = null; + let recoveringEmptyCycling = false; let hasProjectContext = false; let selectedAction = 'impeccable'; let selectedCount = 3; @@ -175,14 +204,17 @@ let highlightEl = null; let tooltipEl = null; let barEl = null; + let barHideSeq = 0; let pickerEl = null; let toastEl = null; let scrollRaf = null; let editBadgeEl = null; + let editBadgeProxyRoot = null; + let editBadgeProxyByTarget = new Map(); - // --------------------------------------------------------------------------- + // // Helpers - // --------------------------------------------------------------------------- + // function own(el) { return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); @@ -204,8 +236,105 @@ return s; } + function rectIsUsableAnchor(rect) { + return !!rect && rect.width > 0.5 && rect.height > 0.5; + } + + function makeFrozenAnchor(el) { + if (!el || !el.getBoundingClientRect) return null; + const r = el.getBoundingClientRect(); + if (!rectIsUsableAnchor(r)) return null; + const rect = { + x: r.x, y: r.y, + top: r.top, left: r.left, + right: r.right, bottom: r.bottom, + width: r.width, height: r.height, + }; + return { + __impeccableFrozenAnchor: true, + tagName: el.tagName || 'DIV', + id: el.id || '', + classList: el.classList ? [...el.classList] : [], + hasAttribute: () => false, + getBoundingClientRect: () => rect, + }; + } + function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } + function cssId(id) { + if (window.CSS?.escape) return CSS.escape(id); + return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); + } + + function liveUiRoot() { + const root = window.__IMPECCABLE_LIVE_UI_ROOT__; + if (root && typeof root.appendChild === 'function') return root; + return document.body; + } + + function uiAppend(el) { + liveUiRoot().appendChild(el); + return el; + } + + function uiAppendStyle(styleEl) { + const root = liveUiRoot(); + if (root && root !== document.body) root.appendChild(styleEl); + else document.head.appendChild(styleEl); + return styleEl; + } + + function uiGetById(id) { + const root = liveUiRoot(); + if (root?.getElementById) { + const found = root.getElementById(id); + if (found) return found; + } + if (root?.querySelector) { + const found = root.querySelector('#' + cssId(id)); + if (found) return found; + } + return document.getElementById(id); + } + + function activeElementDeep() { + let active = document.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; + return active; + } + + window.__IMPECCABLE_LIVE_CHROME_CORE__ = { + version: 1, + adapter: window.__IMPECCABLE_LIVE_ADAPTER__ || 'dom', + mountContract: LIVE_CHROME_MOUNT_CONTRACT, + surfaces: LIVE_UI_SURFACES, + componentIds: LIVE_UI_COMPONENT_IDS, + root: liveUiRoot, + append: uiAppend, + appendStyle: uiAppendStyle, + getById: uiGetById, + activeElementDeep, + debugState: () => ({ + state, + currentSessionId, + expectedVariants, + arrivedVariants, + visibleVariant, + savedSession: loadSession(), + sourceFile: currentSourceFile, + previewFile: currentPreviewFile, + previewMode: currentPreviewMode, + barText: barEl?.textContent || null, + barConnected: !!barEl?.isConnected, + hasSvelteComponentSession: !!svelteComponentSession, + mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0, + pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver, + recoveryWaitingForAnchor, + evtSourceReadyState: evtSource ? evtSource.readyState : null, + }), + }; + // Modal-aware chrome: keep our floating UI clickable inside Radix / // Headless UI / vaul portals. // @@ -245,9 +374,9 @@ rootEl.addEventListener('focusin', stop); } - // --------------------------------------------------------------------------- + // // Highlight overlay - // --------------------------------------------------------------------------- + // function initHighlight() { highlightEl = document.createElement('div'); @@ -259,7 +388,7 @@ transition: HIGHLIGHT_TRANSITION, display: 'none', opacity: '0', }); - document.body.appendChild(highlightEl); + uiAppend(highlightEl); tooltipEl = document.createElement('div'); tooltipEl.id = PREFIX + '-tooltip'; @@ -273,7 +402,7 @@ letterSpacing: '0.02em', transition: TOOLTIP_TRANSITION, }); - document.body.appendChild(tooltipEl); + uiAppend(tooltipEl); } function showHighlight(el) { @@ -310,7 +439,7 @@ if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; } } - // --------------------------------------------------------------------------- + // // Annotation overlay (comment pins + kinpaku strokes) // // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned @@ -318,7 +447,7 @@ // drag) drops a comment pin; drag paints a kinpaku SVG stroke. All coords // are stored in element-local CSS px so they survive scroll / resize and // correlate directly with the captured PNG. - // --------------------------------------------------------------------------- + // const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it @@ -397,7 +526,7 @@ annotOverlayEl.addEventListener('pointermove', onAnnotMove); annotOverlayEl.addEventListener('pointerup', onAnnotUp); annotOverlayEl.addEventListener('pointercancel', onAnnotUp); - document.body.appendChild(annotOverlayEl); + uiAppend(annotOverlayEl); // Modal-host friendliness: pointer-events is already 'auto' on this // overlay; we only need to silence the host's outside-interaction // listeners. Don't override pointer-events here (the overlay toggles @@ -828,9 +957,9 @@ return wrap; } - // --------------------------------------------------------------------------- + // // Element context extraction - // --------------------------------------------------------------------------- + // function stripManualEditRuntimeState(root) { if (!root || root.nodeType !== 1) return; @@ -971,9 +1100,9 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } - // --------------------------------------------------------------------------- + // // The Bar - one floating element, three modes - // --------------------------------------------------------------------------- + // // Contextual-bar palette. Cached at init so every build*Row reads a // consistent set of colors; detectPageTheme runs once rather than on every @@ -1006,7 +1135,7 @@ padding: '6px', maxWidth: '520px', minWidth: '320px', }); - document.body.appendChild(barEl); + uiAppend(barEl); defangOutsideHandlers(barEl); } @@ -1041,6 +1170,8 @@ } function showBar(mode) { + barHideSeq += 1; + if (mode === 'cycling' && !ensureCyclingRenderable('show-bar')) return; barEl.innerHTML = ''; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); @@ -1058,11 +1189,12 @@ function hideBar() { if (!barEl) return; + const hideSeq = ++barHideSeq; stopVoice({ suppressSubmit: true }); if (configureKind === 'insert') clearInsertPicking(); barEl.style.opacity = '0'; barEl.style.transform = 'translateY(6px)'; - setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); + setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); if (state === 'EDITING') restoreInlineEditDrafts(); @@ -1071,6 +1203,7 @@ function updateBarContent(mode) { if (!barEl || barEl.style.display === 'none') return; + if (mode === 'cycling' && !ensureCyclingRenderable('update-bar')) return; barEl.innerHTML = ''; // Reset bar styling to the kinpaku picker palette barEl.style.background = BP.surface; @@ -1090,13 +1223,13 @@ syncPageChatFocus('update-bar-content'); } - // --- Configure row --- + // Configure row function syncConfigureInputChrome() { - const wrap = document.getElementById(PREFIX + '-configure-input-wrap'); - const input = document.getElementById(PREFIX + '-input'); + const wrap = uiGetById(PREFIX + '-configure-input-wrap'); + const input = uiGetById(PREFIX + '-input'); if (!wrap || !input) return; - const focused = document.activeElement === input; + const focused = activeElementDeep() === input; wrap.dataset.inputFocused = focused ? 'true' : 'false'; wrap.dataset.voiceListening = (voiceListening && voiceCtx?.mode === 'configure') ? 'true' : 'false'; wrap.style.borderColor = (voiceListening && voiceCtx?.mode === 'configure') @@ -1104,7 +1237,7 @@ : (focused ? BP.accentSoft : BP.hairline); } - // --- Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) --- + // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) function detectInsertAxisFromStyle(style) { const display = style?.display || 'block'; @@ -1377,7 +1510,7 @@ display: 'none', opacity: '0.9', }); - document.body.appendChild(insertLineEl); + uiAppend(insertLineEl); defangOutsideHandlers(insertLineEl); return insertLineEl; } @@ -1440,6 +1573,10 @@ /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { + if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor) return anchor; + } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (wrapper) { @@ -1557,6 +1694,11 @@ positionBar(); } + function showOrUpdateCyclingBar() { + if (barEl && barEl.style.display !== 'none') updateBarContent('cycling'); + else showBar('cycling'); + } + function buildPlaceholderResizeHandles() { if (!placeholderResizeLayerEl) return; placeholderResizeLayerEl.innerHTML = ''; @@ -1665,7 +1807,7 @@ } function isInsertCreateEnabled(btn) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); + btn = btn || uiGetById(PREFIX + '-insert-create'); return !!btn && btn.getAttribute('aria-disabled') !== 'true'; } @@ -1691,7 +1833,7 @@ lineHeight: '1.35', }); insertCreateTooltipEl.id = PREFIX + '-insert-create-tooltip'; - document.body.appendChild(insertCreateTooltipEl); + uiAppend(insertCreateTooltipEl); return insertCreateTooltipEl; } @@ -1723,8 +1865,8 @@ } function syncInsertCreateButton(btn, input) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); - input = input || document.getElementById(PREFIX + '-insert-input'); + btn = btn || uiGetById(PREFIX + '-insert-create'); + input = input || uiGetById(PREFIX + '-insert-input'); if (!btn || !input) return; const gate = insertCreateGateState(input); const ok = canCreateInsert(gate); @@ -1833,7 +1975,7 @@ voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!document.getElementById(PREFIX + '-configure-input-style')) { + if (!uiGetById(PREFIX + '-configure-input-style')) { const s = document.createElement('style'); s.id = PREFIX + '-configure-input-style'; s.textContent = @@ -1842,7 +1984,7 @@ '#' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: impeccable-configure-voice-pulse 1.1s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-configure-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } input.addEventListener('focus', () => syncConfigureInputChrome()); @@ -1949,6 +2091,9 @@ transition: 'border-color 0.15s ease', }); inputWrap.id = PREFIX + '-insert-input-wrap'; + inputWrap.addEventListener('pointerdown', (e) => e.stopPropagation()); + inputWrap.addEventListener('mousedown', (e) => e.stopPropagation()); + inputWrap.addEventListener('click', (e) => e.stopPropagation()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; @@ -1984,6 +2129,12 @@ voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; input.addEventListener('input', () => syncInsertCreateButton()); + input.addEventListener('pointerdown', (e) => e.stopPropagation()); + input.addEventListener('mousedown', (e) => e.stopPropagation()); + input.addEventListener('click', (e) => { + e.stopPropagation(); + try { input.focus({ preventScroll: true }); } catch { input.focus(); } + }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); @@ -2049,6 +2200,7 @@ }); create.addEventListener('mouseleave', hideInsertCreateTooltip); create.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); if (controlsLocked) { showManualApplyBusyToast(); return; } if (!isInsertCreateEnabled(create)) return; @@ -2060,7 +2212,7 @@ return row; } - // --- Generating row --- + // Generating row function buildGeneratingRow() { const row = el('div', { @@ -2086,19 +2238,24 @@ }); // Variants currently arrive atomically in a single file edit, so a // per-variant counter would lie. Say what's true. - status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + expectedVariants + ' variants...' - : 'Done'; + status.textContent = recoveryWaitingForAnchor + ? 'Variants ready. Reveal the selected element to resume.' + : (arrivedVariants < expectedVariants + ? 'Generating ' + expectedVariants + ' variants...' + : 'Done'); row.appendChild(status); return row; } - // --- Cycling row --- + // Cycling row const TUNE_ICON_SVG = ''; function buildCyclingRow() { + if (!ensureCyclingRenderable('build-cycling-row')) { + return el('div', { display: 'none' }); + } const row = el('div', { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 2px', @@ -2106,6 +2263,7 @@ // Prev const prev = navBtn('\u2190'); + prev.id = PREFIX + '-variant-prev'; prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); }); if (visibleVariant <= 1) prev.style.opacity = '0.3'; row.appendChild(prev); @@ -2118,11 +2276,13 @@ fontFamily: MONO, fontSize: '11px', fontWeight: '500', color: BP.textDim, minWidth: '24px', textAlign: 'center', }); + counter.id = PREFIX + '-variant-counter'; counter.textContent = visibleVariant + '/' + arrivedVariants; row.appendChild(counter); // Next const next = navBtn('\u2192'); + next.id = PREFIX + '-variant-next'; next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); }); if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3'; row.appendChild(next); @@ -2208,9 +2368,9 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders - // --- Saving row (waiting for agent to process accept/discard) --- + // Saving row (waiting for agent to process accept/discard) function buildSavingRow() { const row = el('div', { @@ -2235,7 +2395,7 @@ return row; } - // --- Confirmed row (green success, auto-dismisses) --- + // Confirmed row (green success, auto-dismisses) function buildConfirmedRow() { const row = el('div', { @@ -2256,7 +2416,7 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders function buildDots(clickable) { const container = el('div', { @@ -2290,10 +2450,7 @@ const idx = i; dot.addEventListener('click', (e) => { e.stopPropagation(); - visibleVariant = idx; - showVariantInDOM(currentSessionId, idx); - updateSelectedElement(); - updateBarContent('cycling'); + selectVariant(idx, 'variant_changed'); }); } container.appendChild(dot); @@ -2323,13 +2480,14 @@ function el(tag, styles) { const e = document.createElement(tag); + if (String(tag).toLowerCase() === 'button') e.type = 'button'; if (styles) Object.assign(e.style, styles); return e; } - // --------------------------------------------------------------------------- + // // Action picker popover - // --------------------------------------------------------------------------- + // function initActionPicker() { const P = barPaletteForTheme(detectPageTheme()); @@ -2384,16 +2542,20 @@ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent'; }); chip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + const prompt = uiGetById(PREFIX + '-input')?.value || ''; selectedAction = action.value; hideActionPicker(); updateBarContent('configure'); + const input = uiGetById(PREFIX + '-input'); + if (input && prompt) input.value = prompt; }); grid.appendChild(chip); }); pickerEl.appendChild(grid); - document.body.appendChild(pickerEl); + uiAppend(pickerEl); defangOutsideHandlers(pickerEl); // Cache the palette on the picker so toggleActionPicker's state refresh @@ -2433,7 +2595,33 @@ setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180); } - // --------------------------------------------------------------------------- + function ensureCyclingRenderable(reason) { + if (arrivedVariants > 0) { + if (visibleVariant < 1 || visibleVariant > arrivedVariants) visibleVariant = 1; + return true; + } + recoverEmptyCycling(reason); + return false; + } + + function recoverEmptyCycling(reason) { + if (recoveringEmptyCycling) return; + recoveringEmptyCycling = true; + try { + console.warn('[impeccable] Refusing to render empty variant cycling state:', reason); + const message = 'No variants were mounted. Please try again.'; + if (svelteComponentSession?.sessionId === currentSessionId) { + abortSvelteComponentInjection(currentSessionId, message); + return; + } + cleanup(); + showToast(message, 5000); + } finally { + recoveringEmptyCycling = false; + } + } + + // // Params panel (per-variant coarse controls) // // Variants may declare a parameter manifest via a JSON attribute on the @@ -2446,13 +2634,13 @@ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped // CSS can respond instantly without regeneration: // - // range / numeric toggle → CSS var (`--p-`) used via var(--p-foo, N) + // range / numeric toggle -> CSS custom property used by variant styles // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."] // // On variant switch, values reset to that variant's declared defaults. // On accept, current values are sent in the event payload so the agent // can bake them into the source-file write. - // --------------------------------------------------------------------------- + // let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide) let paramsPanelInner = null; // translating content (carries bg, padding, knobs) @@ -2507,7 +2695,7 @@ }); paramsPanelEl.appendChild(paramsPanelBody); - document.body.appendChild(paramsPanelEl); + uiAppend(paramsPanelEl); // Don't override pointer-events: the panel toggles between 'none' (closed, // click-through) and 'auto' (open) on its own. Just silence the host's // outside-interaction listeners while the panel is open. @@ -2516,14 +2704,40 @@ } + function getMountedSvelteComponentAnchor(session = svelteComponentSession) { + const el = session?.mountTargetEl?.firstElementChild || null; + if (!el || !document.body.contains(el)) return null; + return rectIsUsableAnchor(el.getBoundingClientRect()) ? el : null; + } + + function resolveSvelteComponentAnchor(session = svelteComponentSession) { + return getMountedSvelteComponentAnchor(session) + || session?.swapAnchor + || null; + } + function getVisibleVariantEl() { if (!currentSessionId) return null; + if (svelteComponentSession?.sessionId === currentSessionId) { + return resolveSvelteComponentAnchor() + || svelteComponentSession.wrapperEl + || null; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } function parseVariantParams(variantEl) { + // Svelte component variants can't carry a `data-impeccable-params` attribute: + // the compiler reads `{` inside attribute values as expression delimiters, so + // JSON-with-braces breaks the build. For that path the params live in a sidecar + // params.json keyed by variant number, loaded into the session at mount time. + if (svelteComponentSession?.sessionId === currentSessionId) { + const byVariant = svelteComponentSession.paramsByVariant || {}; + const params = byVariant[String(visibleVariant)] || byVariant[visibleVariant]; + return Array.isArray(params) ? params : []; + } if (!variantEl) return []; const raw = variantEl.getAttribute('data-impeccable-params'); if (!raw) return []; @@ -2685,11 +2899,11 @@ } } - // --------------------------------------------------------------------------- + // // Inline text editing - makes pure-text descendants of the picked element // directly contenteditable. Save stages copy edits in the live buffer; the // Apply copy edits dock later asks the AI to apply the staged batch. - // --------------------------------------------------------------------------- + // let inlineEditRows = []; let inlineEditDrafts = new Map(); @@ -2803,7 +3017,7 @@ function disableInlineEdit(opts = {}) { for (const row of inlineEditRows) { - if (document.activeElement === row.el) row.el.blur(); + if (activeElementDeep() === row.el) row.el.blur(); row.el.removeAttribute('contenteditable'); delete row.el.dataset.impeccableEditable; delete row.el.dataset.impeccableOriginalText; @@ -3133,7 +3347,7 @@ if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) { showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); } else { - showToast('Save failed: retry or cancel', 4000); + showToast('Save failed - retry or cancel', 4000); } } } @@ -3181,11 +3395,11 @@ } function ensureSpinKeyframes() { - if (document.getElementById(PREFIX + '-keyframes')) return; + if (uiGetById(PREFIX + '-keyframes')) return; const style = document.createElement('style'); style.id = PREFIX + '-keyframes'; style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }'; - document.head.appendChild(style); + uiAppendStyle(style); } function pendingApplyLabel(count) { @@ -3318,10 +3532,10 @@ closeTunePopover(); } if (barEl && barEl.style.display !== 'none' && state === 'CONFIGURING') { - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value : ''; updateBarContent('configure'); - const nextInput = document.getElementById(PREFIX + '-input'); + const nextInput = uiGetById(PREFIX + '-input'); if (nextInput) nextInput.value = prompt; } if (editBadgeEl && editBadgeEl.style.display !== 'none') { @@ -3455,19 +3669,19 @@ updatePendingCounter(remaining); if (result.failed && result.failed.length > 0) { console.warn('[impeccable] some copy edits failed:', result.failed); - showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed - see console', 5000); } else { const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0); if (n > 0) { showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); } else { console.warn('[impeccable] apply returned no verified edits:', result); - showToast('No edits applied, see console', 4000); + showToast('No edits applied - see console', 4000); } } } catch (err) { console.error('[impeccable] commit failed:', err); - showToast('Apply failed, see console', 4000); + showToast('Apply failed - see console', 4000); } finally { if (waitForSseCompletion) return; const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0; @@ -3497,7 +3711,7 @@ } } catch (err) { console.error('[impeccable] discard failed:', err); - showToast('Discard failed, see console', 4000); + showToast('Discard failed - see console', 4000); } } @@ -3645,7 +3859,7 @@ const failedCount = numberOrNull(msg.failedCount) || 0; const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0; if (failedCount > 0) { - showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000); + showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed - see console', 5000); } else if (appliedCount > 0) { showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500); } @@ -3799,9 +4013,164 @@ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); } - // --------------------------------------------------------------------------- + // // Edit content badge - floating button at element top-right to enter EDITING mode - // --------------------------------------------------------------------------- + // + + function usesShadowChromeRoot() { + const root = liveUiRoot(); + return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; + } + + function setImportantStyle(el, name, value) { + el.style.setProperty(name, value, 'important'); + } + + function initEditBadgeHitProxies() { + if (!usesShadowChromeRoot() || editBadgeProxyRoot) return; + editBadgeProxyRoot = document.createElement('div'); + editBadgeProxyRoot.id = PREFIX + '-edit-badge-hit-proxies'; + editBadgeProxyRoot.setAttribute('aria-hidden', 'true'); + const styles = { + all: 'initial', + position: 'fixed', + inset: '0', + width: '100vw', + height: '100vh', + zIndex: String(Z.toast + 1), + pointerEvents: 'none', + background: 'transparent', + overflow: 'visible', + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + document.body.appendChild(editBadgeProxyRoot); + } + + function styleEditBadgeProxy(proxy, target) { + const rect = target.getBoundingClientRect(); + const cursor = getComputedStyle(target).cursor || 'pointer'; + const styles = { + all: 'initial', + position: 'fixed', + left: rect.left + 'px', + top: rect.top + 'px', + width: rect.width + 'px', + height: rect.height + 'px', + margin: '0', + padding: '0', + border: '0', + borderRadius: '0', + background: 'transparent', + color: 'transparent', + opacity: '0.001', + pointerEvents: 'auto', + cursor, + zIndex: String(Z.toast + 2), + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(proxy, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + } + + function proxyMouseEvent(type, source, target) { + let event; + try { + event = new MouseEvent(type, { + bubbles: type !== 'mouseenter' && type !== 'mouseleave', + cancelable: true, + composed: true, + clientX: source.clientX, + clientY: source.clientY, + screenX: source.screenX, + screenY: source.screenY, + button: source.button || 0, + buttons: source.buttons || 0, + ctrlKey: source.ctrlKey, + metaKey: source.metaKey, + shiftKey: source.shiftKey, + altKey: source.altKey, + }); + target.dispatchEvent(event); + } catch {} + } + + function bindEditBadgeProxy(proxy, target) { + const stop = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + proxy.addEventListener('mouseenter', (event) => { + stop(event); + proxyMouseEvent('mouseenter', event, target); + proxyMouseEvent('mouseover', event, target); + }); + proxy.addEventListener('mouseleave', (event) => { + stop(event); + proxyMouseEvent('mouseleave', event, target); + proxyMouseEvent('mouseout', event, target); + }); + proxy.addEventListener('mousedown', (event) => { + stop(event); + target.focus?.({ preventScroll: true }); + proxyMouseEvent('mousedown', event, target); + }); + proxy.addEventListener('mouseup', (event) => { + stop(event); + proxyMouseEvent('mouseup', event, target); + }); + proxy.addEventListener('click', (event) => { + stop(event); + target.click(); + syncEditBadgeHitProxies(); + }); + } + + function editBadgeProxyTargets() { + if (!usesShadowChromeRoot() || !editBadgeEl || editBadgeEl.style.display === 'none') return []; + return [...editBadgeEl.querySelectorAll('button')].filter((target) => { + if (target.disabled) return false; + const rect = target.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return false; + const style = getComputedStyle(target); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); + } + + function syncEditBadgeHitProxies() { + if (!usesShadowChromeRoot()) { + if (editBadgeProxyRoot) editBadgeProxyRoot.remove(); + editBadgeProxyRoot = null; + editBadgeProxyByTarget = new Map(); + return; + } + initEditBadgeHitProxies(); + if (!editBadgeProxyRoot) return; + const targets = editBadgeProxyTargets(); + const active = new Set(targets); + for (const [target, proxy] of editBadgeProxyByTarget) { + if (!active.has(target) || !target.isConnected) { + proxy.remove(); + editBadgeProxyByTarget.delete(target); + } + } + for (const target of targets) { + let proxy = editBadgeProxyByTarget.get(target); + if (!proxy) { + proxy = document.createElement('button'); + proxy.type = 'button'; + proxy.tabIndex = -1; + proxy.dataset.impeccableEditBadgeProxy = 'true'; + proxy.setAttribute('aria-hidden', 'true'); + bindEditBadgeProxy(proxy, target); + editBadgeProxyRoot.appendChild(proxy); + editBadgeProxyByTarget.set(target, proxy); + } + proxy.title = target.title || target.textContent || 'Edit copy'; + styleEditBadgeProxy(proxy, target); + } + } function initEditBadge() { editBadgeEl = document.createElement('div'); @@ -3813,10 +4182,11 @@ display: 'none', userSelect: 'none', }); - document.body.appendChild(editBadgeEl); + uiAppend(editBadgeEl); + initEditBadgeHitProxies(); // Remove focus rings on edit badge buttons + contenteditable elements - if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + if (!uiGetById(PREFIX + '-edit-badge-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-edit-badge-focus-style'; s.textContent = @@ -3826,21 +4196,26 @@ '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; - document.head.appendChild(s); + uiAppendStyle(s); } } function positionEditBadge() { - if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') { + syncEditBadgeHitProxies(); + return; + } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { if (editBadgeEl) editBadgeEl.style.display = 'none'; + syncEditBadgeHitProxies(); return; } editBadgeEl.style.display = 'flex'; @@ -4047,7 +4422,7 @@ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN; } // Re-render the bar so the Tune chip picks up the active styling. - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } function closeTunePopover() { @@ -4055,13 +4430,13 @@ hideParamsPanel(); if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT; if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') { - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } } - // --------------------------------------------------------------------------- + // // Variant cycling in DOM - // --------------------------------------------------------------------------- + // function isVariantShown(el) { if (!el) return false; @@ -4081,9 +4456,42 @@ } } - function showVariantInDOM(sessionId, num) { + function scheduleCyclingBarSync(sessionId, variantNum) { + requestAnimationFrame(() => { + if (state !== 'CYCLING') return; + if (currentSessionId !== sessionId) return; + if (visibleVariant !== variantNum) return; + showOrUpdateCyclingBar(); + syncCyclingControls(); + positionBar(); + }); + } + + function syncCyclingControls() { + const shown = svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0 + ? svelteComponentSession.mountedVariant + : visibleVariant; + const counter = uiGetById(PREFIX + '-variant-counter'); + if (counter && arrivedVariants > 0) counter.textContent = shown + '/' + arrivedVariants; + const prev = uiGetById(PREFIX + '-variant-prev'); + const next = uiGetById(PREFIX + '-variant-next'); + if (prev) prev.style.opacity = shown <= 1 ? '0.3' : '1'; + if (next) next.style.opacity = shown >= arrivedVariants ? '0.3' : '1'; + if (currentSessionId && state === 'CYCLING') saveSession(); + } + + async function showVariantInDOM(sessionId, num) { + if (svelteComponentSession?.sessionId === sessionId) { + visibleVariant = num; + const mounted = await mountSvelteComponentVariant(num); + if (!mounted) return false; + updateSelectedElement(); + refreshParamsPanel(); + scheduleCyclingBarSync(sessionId, num); + return true; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); - if (!wrapper) return; + if (!wrapper) return false; for (const child of wrapper.children) { const v = child.dataset ? child.dataset.impeccableVariant : null; if (!v) continue; @@ -4093,6 +4501,378 @@ // CYCLING yet, the subsequent CYCLING transition triggers its own // refresh) and every cycle step. refreshParamsPanel(); + return true; + } + + function isSvelteComponentManifestPath(filePath) { + return String(filePath || '').endsWith('manifest.json'); + } + + function parseOriginalMarkupElement(originalMarkup) { + const parser = new DOMParser(); + const doc = parser.parseFromString('
' + originalMarkup + '
', 'text/html'); + return doc.getElementById('impeccable-anchor')?.firstElementChild || null; + } + + function findLiveElementForOriginalMarkup(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const tag = origContent.tagName.toLowerCase(); + const cls = origContent.className; + let liveEl = null; + if (origContent.id) { + liveEl = document.getElementById(origContent.id); + } else if (cls) { + const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + for (const c of candidates) { + if (c.className === cls && !own(c)) { liveEl = c; break; } + } + if (!liveEl) { + const expectedClasses = String(cls).split(/\s+/).filter(Boolean); + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } + } + } + return liveEl; + } + + function isSvelteInsertManifest(manifest) { + return manifest?.previewMode === 'svelte-component' && manifest?.mode === 'insert'; + } + + function findLiveElementForSvelteManifest(manifest) { + if (isSvelteInsertManifest(manifest)) { + const anchor = findInsertAnchorInDom(); + if (anchor?.parentElement) return anchor; + } + return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function loadSvelteRuntime(runtimeModule) { + const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js'; + const url = new URL(modulePath, location.origin).href; + if (!svelteRuntimePromise) { + svelteRuntimePromise = import(/* @vite-ignore */ url); + } + return svelteRuntimePromise; + } + + // Svelte component variants declare their params in a sidecar params.json under + // componentDir (keyed by variant number), because a `data-impeccable-params` + // attribute with JSON braces can't survive the Svelte compiler. Returns a map of + // { "1": [...params], "2": [...] }; an empty object when the agent declared none. + async function loadSvelteComponentParams(manifest) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir) return {}; + const paramsPath = dir + '/params.json'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(paramsPath); + try { + const res = await fetch(url); + if (!res.ok) return {}; + const parsed = JSON.parse(await res.text()); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out = {}; + for (const [key, value] of Object.entries(parsed)) { + if (Array.isArray(value)) out[String(key)] = value; + } + return out; + } catch { + return {}; + } + } + + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { + const contract = manifest?.propContract || []; + const values = {}; + if (!liveEl || contract.length === 0) return values; + const sourceOriginal = parseOriginalMarkupElement(manifest.originalMarkup || ''); + if (!sourceOriginal) return values; + const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl); + for (const entry of contract) { + const token = '{' + entry.expr + '}'; + values[entry.prop] = map.get(token) || ''; + } + return values; + } + + async function mountSvelteComponentVariant(variantNum) { + if (!svelteComponentSession || !variantNum) return false; + const { manifest, mountTargetEl, sessionId } = svelteComponentSession; + try { + const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement; + svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null; + const runtime = await loadSvelteRuntime(manifest.runtimeModule); + const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte'; + const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now(); + const mod = await import(/* @vite-ignore */ moduleUrl); + const Component = mod.default; + if (svelteComponentSession.mountedInstance && runtime.unmount) { + await runtime.unmount(svelteComponentSession.mountedInstance); + svelteComponentSession.mountedInstance = null; + } + svelteComponentSession.mountedInstance = runtime.mount(Component, { + target: mountTargetEl, + props: { ...svelteComponentSession.propValues }, + intro: false, + }); + svelteComponentSession.mountedVariant = variantNum; + svelteComponentSession.runtime = runtime; + if (state === 'CYCLING') syncCyclingControls(); + const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (nextAnchor) { + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(nextAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = nextAnchor; + } else { + requestAnimationFrame(() => { + if (svelteComponentSession?.sessionId !== sessionId) return; + const settledAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!settledAnchor) return; + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(settledAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = settledAnchor; + }); + } + return true; + } catch (err) { + if (svelteComponentSession?.sessionId === sessionId) { + svelteComponentSession.swapAnchor = null; + } + console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err); + return false; + } + } + + function teardownSvelteComponentSession(restoreOriginal) { + if (!svelteComponentSession) return; + const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + if (restoreOriginal && detachedOriginal && wrapperEl?.parentElement) { + wrapperEl.parentElement.replaceChild(detachedOriginal, wrapperEl); + } else if (wrapperEl?.parentElement) { + wrapperEl.remove(); + } + svelteComponentSession = null; + svelteRuntimePromise = null; + } + + function applyOriginalAttrsToSvelteAnchor(el, originalMarkup) { + if (!el || !originalMarkup) return; + const original = parseOriginalMarkupElement(originalMarkup); + if (!original || original.tagName !== el.tagName) return; + for (const attr of original.attributes) { + if (attr.name === 'class') { + for (const className of attr.value.split(/\s+/).filter(Boolean)) { + el.classList.add(className); + } + } else if (!el.hasAttribute(attr.name)) { + el.setAttribute(attr.name, attr.value); + } + } + } + + function commitAcceptedSvelteComponentToDom(sessionId) { + if (!svelteComponentSession || svelteComponentSession.sessionId !== sessionId) return false; + const { wrapperEl, runtime, mountedInstance, manifest } = svelteComponentSession; + const anchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!anchor || !wrapperEl?.parentElement) return false; + const committed = anchor.cloneNode(true); + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(committed, manifest.originalMarkup || ''); + } + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + wrapperEl.parentElement.replaceChild(committed, wrapperEl); + svelteComponentSession = null; + svelteRuntimePromise = null; + selectedElement = committed; + return true; + } + + async function injectSvelteComponentsFromManifest(manifestPath, sessionId) { + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(manifestPath); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + const manifest = JSON.parse(await res.text()); + if (manifest.id !== sessionId) return; + + const paramsByVariant = await loadSvelteComponentParams(manifest); + currentSessionId = sessionId; + expectedVariants = Number(manifest.count) || expectedVariants || 1; + rememberSessionFileMeta({ + sourceFile: manifest.sourceFile, + previewFile: manifestPath, + previewMode: 'svelte-component', + }); + if (state !== 'CYCLING') state = 'GENERATING'; + + const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { + recoveryWaitingForAnchor = false; + svelteComponentSession.paramsByVariant = paramsByVariant; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; + await mountSvelteComponentVariant(visibleVariant || 1); + state = 'CYCLING'; + showOrUpdateCyclingBar(); + saveSession(); + return; + } + + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) { + console.warn('[impeccable] Could not find original element in live DOM.'); + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants + ? visibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + saveSession(); + queueCheckpoint('svelte_component_anchor_missing'); + waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return; + } + + const wrapper = document.createElement('div'); + wrapper.dataset.impeccableVariants = sessionId; + wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1); + wrapper.dataset.impeccablePreview = 'svelte-component'; + wrapper.style.display = 'contents'; + + const mountTarget = document.createElement('div'); + mountTarget.dataset.impeccableComponentMount = sessionId; + mountTarget.style.display = 'contents'; + wrapper.appendChild(mountTarget); + + const insertMode = isSvelteInsertManifest(manifest); + const detachedOriginal = insertMode ? null : liveEl; + if (insertMode) { + removeInsertPlaceholderDom(); + if (manifest.position === 'before') liveEl.parentElement.insertBefore(wrapper, liveEl); + else liveEl.parentElement.insertBefore(wrapper, liveEl.nextSibling); + } else { + liveEl.parentElement.replaceChild(wrapper, liveEl); + } + + svelteComponentSession = { + sessionId, + manifest, + insertMode, + wrapperEl: wrapper, + mountTargetEl: mountTarget, + detachedOriginal, + mountedInstance: null, + mountedVariant: 0, + runtime: null, + propValues: buildSveltePropValuesFromLiveElement(detachedOriginal, manifest), + paramsByVariant, + }; + if (pendingSvelteComponentRetryObserver) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + } + recoveryWaitingForAnchor = false; + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + + const mounted = await mountSvelteComponentVariant(visibleVariant); + if (!mounted) { + // The compiled component threw (e.g. a Svelte compile error in the + // variant file). Don't strand the bar in an empty CYCLING state; restore + // the original element and reset to PICKING so the user can retry. + abortSvelteComponentInjection(sessionId, 'A variant failed to compile. Fix the component and re-run.'); + return; + } + + selectedElement = mountTarget.firstElementChild || mountTarget; + state = 'CYCLING'; + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.'); + } catch (err) { + console.error('[impeccable] Failed to mount Svelte component variants:', err); + abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.'); + } + } + + function waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }) { + if (pendingSvelteComponentRetryObserver) pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = new MutationObserver(() => { + if (svelteComponentSession?.sessionId === sessionId) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + return; + } + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) return; + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + injectSvelteComponentsFromManifest(manifestPath, sessionId); + }); + pendingSvelteComponentRetryObserver.observe(document.body, { childList: true, subtree: true }); + } + + // Reset cleanly when a Svelte component session can't mount: tear the wrapper + // down (restoring the original element), clear persisted session state, and + // return the bar to PICKING. Avoids the stuck 0/0 CYCLING bar. + function abortSvelteComponentInjection(sessionId, message) { + try { + if (svelteComponentSession?.sessionId === sessionId) { + teardownSvelteComponentSession(true); + } else { + const orphan = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (orphan) orphan.remove(); + } + } catch (err) { + console.warn('[impeccable] Svelte component abort cleanup failed:', err); + } + hideShaderOverlay(); + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + stopScrollLock(); + clearSession(); + clearHandled(); + resetSessionFileMeta(); + currentSessionId = null; + expectedVariants = 0; + arrivedVariants = 0; + visibleVariant = 0; + selectedElement = null; + state = 'PICKING'; + hideBar(); + if (message) showToast(message, 5000); } /** @@ -4101,6 +4881,11 @@ * This works even when the dev server caches HTML (Bun, static servers). */ function injectVariantsFromSource(filePath, sessionId) { + if (isSvelteComponentManifestPath(filePath)) { + injectSvelteComponentsFromManifest(filePath, sessionId); + return; + } + rememberSessionFileMeta({ file: filePath }); const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) @@ -4119,7 +4904,7 @@ const doc = parser.parseFromString(block, 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { - console.error('[impeccable] Variant wrapper not found in source file.'); + console.warn('[impeccable] Variant wrapper not found in source file.'); return; } @@ -4134,31 +4919,31 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; - if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - } - + const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); if (!liveEl) { - console.error('[impeccable] Could not find original element in live DOM.'); + console.warn('[impeccable] Could not find original element in live DOM.'); + selectedElement = document.body; + recoveryWaitingForAnchor = true; + state = 'GENERATING'; + showBar('generating'); + saveSession(); + showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } + recoveryWaitingForAnchor = false; // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); arrivedVariants = variants.length; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + recoverEmptyCycling('source-fallback-empty'); + return; + } const saved = loadSession(); const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants @@ -4170,8 +4955,9 @@ selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4184,21 +4970,129 @@ }); } - function cycleVariant(dir) { + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { + const map = new Map(); + if (!sourceOriginal || !liveOriginal) return map; + + const sourceNodes = collectTextNodes(sourceOriginal) + .filter((node) => /\{[^{}]+\}/.test(node.nodeValue || '')); + const liveTexts = collectTextNodes(liveOriginal) + .map((node) => normalizePreviewText(node.nodeValue || '')) + .filter(Boolean); + let liveIndex = 0; + + for (const sourceNode of sourceNodes) { + const sourceText = sourceNode.nodeValue || ''; + const tokens = sourceText.match(/\{[^{}]+\}/g) || []; + if (tokens.length === 0) continue; + + const liveText = liveTexts[liveIndex++] || ''; + if (!liveText) continue; + + if (tokens.length === 1) { + const token = tokens[0]; + const normalizedSource = normalizePreviewText(sourceText); + if (normalizedSource === token) { + map.set(token, liveText); + continue; + } + + const match = liveText.match(expressionTextMatcher(sourceText, [token])); + if (match && match[1]) map.set(token, match[1].trim()); + continue; + } + + if (normalizePreviewText(sourceText) === tokens.join(' ')) { + for (const token of tokens) { + const tokenLiveText = liveTexts[liveIndex - 1] || ''; + if (tokenLiveText) map.set(token, tokenLiveText); + } + } + } + + return map; + } + + function expressionTextMatcher(sourceText, tokens) { + let pattern = '^'; + let cursor = 0; + for (const token of tokens) { + const index = sourceText.indexOf(token, cursor); + if (index === -1) continue; + pattern += escapeRegExp(sourceText.slice(cursor, index)).replace(/\s+/g, '\\s*'); + pattern += '(.*?)'; + cursor = index + token.length; + } + pattern += escapeRegExp(sourceText.slice(cursor)).replace(/\s+/g, '\\s*') + '$'; + return new RegExp(pattern); + } + + function collectTextNodes(root) { + if (!root) return []; + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + nodes.push(node); + node = walker.nextNode(); + } + return nodes; + } + + function normalizePreviewText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + async function selectVariant(next, checkpointReason) { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - const next = visibleVariant + dir; + if (variantSelectionInFlight) return; if (next < 1 || next > arrivedVariants) return; - visibleVariant = next; - showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself - updateSelectedElement(); - updateBarContent('cycling'); - positionBar(); - saveSession(); - queueCheckpoint('variant_changed'); + if (next === visibleVariant) return; + + const previous = visibleVariant; + variantSelectionInFlight = true; + const selectionPromise = (async () => { + visibleVariant = next; + showOrUpdateCyclingBar(); + saveSession(); + const shown = await showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself + if (!shown) { + visibleVariant = previous; + await showVariantInDOM(currentSessionId, previous); + showOrUpdateCyclingBar(); + saveSession(); + return; + } + updateSelectedElement(); + showOrUpdateCyclingBar(); + positionBar(); + saveSession(); + if (checkpointReason) queueCheckpoint(checkpointReason); + })(); + variantSelectionPromise = selectionPromise; + try { + await selectionPromise; + } finally { + if (variantSelectionPromise === selectionPromise) variantSelectionPromise = null; + variantSelectionInFlight = false; + } + } + + function cycleVariant(dir) { + selectVariant(visibleVariant + dir, 'variant_changed'); } function updateSelectedElement() { if (!currentSessionId) return; + if (svelteComponentSession?.sessionId === currentSessionId) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; + return; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); @@ -4206,6 +5100,9 @@ } function readVisibleVariantFromDOM(sessionId) { + if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { + return svelteComponentSession.mountedVariant; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -4341,9 +5238,9 @@ // scrollY that the next resume needs to read. } - // --------------------------------------------------------------------------- + // // MutationObserver for progressive variant reveal - // --------------------------------------------------------------------------- + // function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -4426,10 +5323,11 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); updateSelectedElement(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4445,9 +5343,9 @@ return obs; } - // --------------------------------------------------------------------------- + // // Bar scroll tracking - // --------------------------------------------------------------------------- + // function startScrollTracking() { function tick() { @@ -4483,10 +5381,10 @@ if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; } } - // --------------------------------------------------------------------------- + // // SSE (server→browser) + fetch POST (browser→server) // Zero-dependency replacement for WebSocket. - // --------------------------------------------------------------------------- + // let evtSource = null; let sseRetries = 0; @@ -4509,6 +5407,7 @@ console.log('[impeccable] Live mode connected.'); syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); + restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; syncPageChatFocus('sse-connected'); break; @@ -4531,11 +5430,12 @@ break; case 'done': if (maybeCompleteSteer(msg)) break; + rememberSessionFileMeta(msg); // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); } @@ -4557,7 +5457,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; if (state !== 'GENERATING') return; showToast( - "Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.", + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", 15000, ); }, 2000); @@ -4571,12 +5471,18 @@ // the final complete event. Keep the browser in its recoverable // saving state while the source cleanup is still in flight. break; + case 'discarded': + if (msg.id && msg.id === currentSessionId) { + markSessionHandled(); + cleanup(); + } + break; case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; state = 'CYCLING'; updateBarContent('cycling'); - showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000); + showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; } if (maybeCompleteSteer(msg)) break; @@ -4656,6 +5562,9 @@ expectedVariants, arrivedVariants, visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, paramValues: { ...paramsCurrentValues }, }; } @@ -4665,6 +5574,20 @@ return sendEvent(checkpointPayload(reason)).catch(() => null); } + function sendSteerCheckpoint(id, reason, extra) { + if (!id) return Promise.resolve(null); + return sendEvent({ + type: 'checkpoint', + id, + revision: sessionState.nextCheckpointRevision(), + owner: browserOwner, + phase: 'steer', + reason, + pageUrl: location.pathname, + ...(extra || {}), + }).catch(() => null); + } + function queueCheckpoint(reason) { if (!currentSessionId) return; if (checkpointTimer) clearTimeout(checkpointTimer); @@ -4674,9 +5597,9 @@ }, 120); } - // --------------------------------------------------------------------------- + // // Event handlers - // --------------------------------------------------------------------------- + // function handleMouseMove(e) { if (pendingApplyInFlight) return; @@ -4860,7 +5783,7 @@ // // DISABLED: quick-Go workflows pay an extra harness round trip because // prefetch + generate arrive as two events instead of one. Re-enable with - // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to + // a browser-side debounce (~800-1000ms, cancelled on Go) if we want to // resurrect this. Server validator and skill dispatch remain in place so // flipping this flag is the only change needed. const PREFETCH_ENABLED = false; @@ -4876,6 +5799,14 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + const deepActive = activeElementDeep(); + if ( + deepActive + && own(deepActive) + && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + ) { + return; + } // While a contenteditable text-leaf is focused, let the browser handle // all keys except Escape. Escape cancels the current edit (restores // original text) and blurs without saving, staying in CONFIGURING. @@ -4982,7 +5913,7 @@ if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!selectedElement || state !== 'CONFIGURING') return; stopVoice({ suppressSubmit: true }); - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; // Commit any pending pin edit BEFORE we snapshot annotations. @@ -4996,6 +5927,7 @@ expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); // Flip to GENERATING immediately so the bar morphs without waiting on // capture + upload. The event is emitted from captureAndEmit() once the @@ -5054,7 +5986,7 @@ function handleInsertCreate() { if (!placeholderElement || !insertAnchorElement || state !== 'CONFIGURING' || configureKind !== 'insert') return; - const input = document.getElementById(PREFIX + '-insert-input'); + const input = uiGetById(PREFIX + '-insert-input'); const prompt = input ? input.value.trim() : ''; if (annotEditing) finalizeEditingPin(); const snapshot = { @@ -5064,10 +5996,12 @@ if (!canCreateInsert({ prompt, comments: snapshot.comments, strokes: snapshot.strokes })) return; stopVoice({ suppressSubmit: true }); + pendingAcceptedSession = null; currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); selectedElement = placeholderElement; insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement); @@ -5107,9 +6041,9 @@ captureAndEmit(elForCapture, basePayload, snapshot, captureRect); } - // --------------------------------------------------------------------------- + // // Screenshot capture + upload - // --------------------------------------------------------------------------- + // let msLoadPromise = null; function loadModernScreenshot() { @@ -5120,7 +6054,7 @@ s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; s.onload = () => resolve(window.modernScreenshot); s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; - document.head.appendChild(s); + uiAppendStyle(s); }); return msLoadPromise; } @@ -5235,11 +6169,113 @@ return '#ffffff'; } + function captureChromeNodes() { + const nodes = []; + const add = (node) => { + if (!node || node === document.body || nodes.includes(node)) return; + nodes.push(node); + }; + add(document.getElementById(PREFIX + '-root')); + [ + PREFIX + '-highlight', + PREFIX + '-tooltip', + PREFIX + '-bar', + PREFIX + '-picker', + PREFIX + '-params-panel', + PREFIX + '-insert-line', + PREFIX + '-insert-placeholder', + PREFIX + '-insert-create-tooltip', + PREFIX + '-annot', + PREFIX + '-design-host', + PREFIX + '-toast', + PREFIX + '-shader', + ].forEach((id) => add(uiGetById(id))); + return nodes; + } + + async function hideCaptureChromeForShaderProxy(fn) { + const saved = captureChromeNodes().map((node) => ({ + node, + visibility: node.style.visibility, + priority: node.style.getPropertyPriority('visibility'), + })); + for (const { node } of saved) { + node.style.setProperty('visibility', 'hidden', 'important'); + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + try { + return await fn(); + } finally { + for (const { node, visibility, priority } of saved) { + node.style.setProperty('visibility', visibility, priority); + } + } + } + + function shouldUseAncestorCropShaderProxy(el) { + // TODO: Enable this proxy for React/Vue/etc. adapters once their live + // preview mounts are covered by the same shader regression checks. + const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase(); + if (adapter === 'svelte' || adapter === 'sveltekit') return true; + if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true; + const wrapper = el?.closest?.('[data-impeccable-variants]'); + return wrapper?.dataset?.impeccablePreview === 'svelte-component'; + } + + function paintsShaderProxySurface(node) { + const s = getComputedStyle(node); + return !isTransparentColor(s.backgroundColor) + || (s.backgroundImage && s.backgroundImage !== 'none') + || paintsBackdrop(node); + } + + function findShaderProxyCaptureRoot(el) { + const doc = el.ownerDocument || document; + const er = el.getBoundingClientRect(); + let node = el.parentElement; + while (node && node !== doc.documentElement) { + const nr = node.getBoundingClientRect(); + const containsElement = + nr.width > 0 && nr.height > 0 && + nr.left <= er.left + 0.5 && + nr.top <= er.top + 0.5 && + nr.right >= er.right - 0.5 && + nr.bottom >= er.bottom - 0.5; + if (containsElement && paintsShaderProxySurface(node)) return node; + node = node.parentElement; + } + return null; + } + // Capture the element (with current annotations baked in) and return // { blob, paper }: the PNG Blob, plus the representative backdrop tone for the // shader's halftone ground (so capture, upload, and shader all agree on what // sits behind the element). Shared between the Go flow (uploads the blob) and // the shader-resume path. + async function captureElementFromRenderedAncestor(ms, el, opts) { + const doc = el.ownerDocument || document; + const captureRoot = findShaderProxyCaptureRoot(el); + if (!captureRoot) throw new Error('No painted ancestor for Svelte shader proxy'); + const rootCanvas = await ms.domToCanvas(captureRoot, opts); + const S = opts.scale; + const er = el.getBoundingClientRect(); + const rr = captureRoot.getBoundingClientRect(); + const sx = (er.left - rr.left) * S; + const sy = (er.top - rr.top) * S; + const sw = er.width * S; + const sh = er.height * S; + if (sw <= 0 || sh <= 0) throw new Error('Selected element has no visible capture rect'); + const crop = doc.createElement('canvas'); + crop.width = Math.max(1, Math.round(sw)); + crop.height = Math.max(1, Math.round(sh)); + const cctx = crop.getContext('2d', { willReadFrequently: true }); + cctx.drawImage(rootCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height); + const paper = dominantRgb01(cctx, crop.width, crop.height) || averageRgb01(cctx, crop.width, crop.height); + const blob = await new Promise((res) => crop.toBlob(res, 'image/png')); + if (!blob) throw new Error('Ancestor crop failed to produce a PNG blob'); + return { blob, paper }; + } + async function captureElementToBlob(el, snapshot, rect) { try { if (document.fonts?.ready) await document.fonts.ready; } catch {} const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); @@ -5261,6 +6297,13 @@ scale: Math.min(window.devicePixelRatio || 1, 2), font: fontCssText ? { cssText: fontCssText } : undefined, }; + if (shouldUseAncestorCropShaderProxy(el)) { + try { + return await hideCaptureChromeForShaderProxy(() => captureElementFromRenderedAncestor(ms, el, opts)); + } catch (err) { + console.warn('[impeccable] Svelte ancestor crop capture failed, falling back to element capture:', err); + } + } const bg = resolveCanvasBackground(el); // Fast path: the element paints its own background, or an opaque ancestor // color was found. modern-screenshot bakes that color; paper matches it. @@ -5342,13 +6385,13 @@ sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); } - // --------------------------------------------------------------------------- + // // Shader overlay - renders the captured screenshot as a WebGL texture and // runs an editorial "ink-wash" fragment shader over it during generation. // A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku // and leaving a soft trail. Makes the wait feel like a letterpress scan // instead of a dead spinner. - // --------------------------------------------------------------------------- + // const SHADER_VS = `attribute vec2 a_position; attribute vec2 a_uv; @@ -5502,6 +6545,31 @@ void main() { return n ? [r / n / 255, g / n / 255, b / n / 255] : SHADER_PAPER_FALLBACK; } + // Pick the most common visible color cluster from a crop. A straight average + // gets pulled by text and icons; the dominant bucket usually represents the + // surface the shader should dissolve into. + function dominantRgb01(ctx, w, h) { + const data = ctx.getImageData(0, 0, w, h).data; + const stride = Math.max(1, Math.floor((w * h) / 6000)); + const buckets = new Map(); + for (let p = 0; p < w * h; p += stride) { + const i = p * 4; + if (data[i + 3] < 16) continue; + const key = (data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4); + const bucket = buckets.get(key) || { count: 0, r: 0, g: 0, b: 0 }; + bucket.count += 1; + bucket.r += data[i]; + bucket.g += data[i + 1]; + bucket.b += data[i + 2]; + buckets.set(key, bucket); + } + let best = null; + for (const bucket of buckets.values()) { + if (!best || bucket.count > best.count) best = bucket; + } + return best ? [best.r / best.count / 255, best.g / best.count / 255, best.b / best.count / 255] : null; + } + // Average the backdrop sampled just OUTSIDE an element's rect within a larger // canvas. The ground tone for the dissolve must be the real backdrop, not the // mean of the element's own crop - averaging the crop folds in the element's @@ -5572,7 +6640,7 @@ void main() { fallback.style.backgroundRepeat = 'no-repeat'; fallback.style.outline = '2px dashed ' + C.brand; fallback.style.outlineOffset = '-2px'; - document.body.appendChild(fallback); + uiAppend(fallback); shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl }; } @@ -5582,16 +6650,19 @@ void main() { const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); + const radius = getComputedStyle(el).borderRadius; canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); Object.assign(canvas.style, { position: 'fixed', top: rect.top + 'px', left: rect.left + 'px', width: rect.width + 'px', height: rect.height + 'px', + borderRadius: radius, + overflow: 'hidden', pointerEvents: 'none', zIndex: Z.bar - 1, }); - document.body.appendChild(canvas); + uiAppend(canvas); const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) || canvas.getContext('experimental-webgl'); @@ -5685,8 +6756,12 @@ void main() { frame(); } - function handleAccept() { + async function handleAccept() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } + if (pendingAcceptedSession || state === 'SAVING') return; + if (variantSelectionPromise) { + try { await variantSelectionPromise; } catch { /* failed selection falls back below */ } + } if (!currentSessionId || arrivedVariants === 0) return; const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId); if (domVisibleVariant > 0) visibleVariant = domVisibleVariant; @@ -5696,30 +6771,39 @@ void main() { variantId: String(visibleVariant), pageUrl: location.pathname, }; + const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } + // The accepted variant is already the only visible child of the wrapper + // (all other variants are display:none). HMR from the source rewrite will + // replace the wrapper imminently. Don't eagerly replaceChild here - React + // reconciliation races with our mutation and throws NotFoundError in Next + // 16 / Turbopack. Schedule a fallback that runs the manual swap only if + // HMR hasn't cleaned up by then (keeps static-server flows working). const acceptedSessionId = currentSessionId; const acceptedVariant = visibleVariant; + const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId + || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - pendingAcceptedSession = { - id: acceptedSessionId, - variant: String(acceptedVariant), - ...acceptedSnapshot, - finalizing: false, - }; state = 'SAVING'; updateBarContent('saving'); + pendingAcceptedSession = { + id: acceptedSessionId, + variant: String(acceptedVariant), + isSvelteComponent: acceptedIsSvelteComponent, + ...acceptedSnapshot, + finalizing: false, + }; + saveSession(); sendEvent(acceptPayload, { throwOnError: true }) - .then(() => { - markSessionHandled(); - }) + .then(() => {}) .catch(() => { - pendingAcceptedSession = null; + if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); } @@ -5733,19 +6817,21 @@ void main() { } if (pending.finalizing) return true; pending.finalizing = true; - + markSessionHandled(); + if (pending.isSvelteComponent) { + commitAcceptedSvelteComponentToDom(pending.id); + } state = 'CONFIRMED'; updateBarContent('confirmed'); + scheduleAcceptCleanup(pending); + return true; + } - // Give framework HMR a short chance to render the now-clean accepted - // source. If it misses the update, unwrap the accepted variant after the - // source-side completion event so the page is not left empty or stale. + function scheduleAcceptCleanup(accepted) { setTimeout(function() { - ensureAcceptedDomClean(pending); + if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted); cleanupAcceptedSession(); }, 1200); - - return true; } function snapshotAcceptedVariantDom(sessionId, variantId) { @@ -5833,6 +6919,7 @@ void main() { stopScrollLock(); clearScrollY(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5841,6 +6928,28 @@ void main() { state = 'PICKING'; } + function commitAcceptedVariantToDom(sessionId, variantId) { + const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapper) return false; + const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); + if (!accepted || !accepted.firstElementChild) return false; + const parent = wrapper.parentElement; + if (!parent) return false; + + const style = wrapper.querySelector('style[data-impeccable-css]'); + if (style && !document.querySelector('style[data-impeccable-accepted-css="' + sessionId + '"]')) { + const promotedStyle = style.cloneNode(true); + promotedStyle.setAttribute('data-impeccable-accepted-css', sessionId); + parent.insertBefore(promotedStyle, wrapper); + } + + const committed = accepted.cloneNode(true); + committed.removeAttribute('hidden'); + committed.style.display = 'contents'; + parent.replaceChild(committed, wrapper); + return true; + } + function handleDiscard() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!currentSessionId) return; @@ -5852,11 +6961,141 @@ void main() { .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000)); } - // --------------------------------------------------------------------------- + // // Session persistence via live-browser-session.js - // --------------------------------------------------------------------------- + // // Survives page reloads, browser close/reopen, HMR, and accidental refreshes. + function normalizeSessionPath(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed.replace(/\\/g, '/') : null; + } + + function resetSessionFileMeta() { + currentSourceFile = null; + currentPreviewFile = null; + currentPreviewMode = null; + recoveryWaitingForAnchor = false; + } + + function rememberSessionFileMeta(meta = {}) { + const file = normalizeSessionPath(meta.file); + const sourceFile = normalizeSessionPath(meta.sourceFile); + const previewFile = normalizeSessionPath(meta.previewFile); + const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null); + + if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) { + currentPreviewMode = 'svelte-component'; + currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile); + currentSourceFile = sourceFile || currentSourceFile; + return; + } + + if (sourceFile || file) currentSourceFile = sourceFile || file; + if (previewFile) currentPreviewFile = previewFile; + if (previewMode) currentPreviewMode = previewMode; + } + + function applySavedSessionMeta(saved) { + if (!saved) return; + rememberSessionFileMeta(saved); + if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.action) selectedAction = saved.action; + if (saved.count) selectedCount = saved.count; + if (saved.previewMode) currentPreviewMode = saved.previewMode; + if (saved.paramValues && typeof saved.paramValues === 'object') { + paramsCurrentValues = { ...saved.paramValues }; + } + } + + function normalizePagePath(value) { + if (!value || typeof value !== 'string') return null; + try { + return new URL(value, location.origin).pathname; + } catch { + return value.split(/[?#]/)[0] || null; + } + } + + function pageMatchesCurrent(value) { + const path = normalizePagePath(value); + return !path || path === location.pathname; + } + + function isTerminalSessionSummary(session) { + return /^(completed|discarded|discard_requested|accept_requested)$/.test(String(session?.phase || '')); + } + + function findActiveSessionSummary(saved, activeSessions) { + if (!saved?.id || !Array.isArray(activeSessions)) return null; + return activeSessions.find((session) => + session?.id === saved.id + && pageMatchesCurrent(session.pageUrl || saved.pageUrl) + && !isTerminalSessionSummary(session) + ) || null; + } + + function clampVariantIndex(value, count) { + const num = Number(value); + const max = Number(count); + if (!Number.isFinite(num) || num < 1) return 0; + if (Number.isFinite(max) && max > 0 && num > max) return 0; + return Math.floor(num); + } + + function restoreSessionWithoutWrapper(reason, activeSessions) { + const saved = loadSession(); + if (!saved?.id || isSessionHandled(saved.id)) return false; + const savedState = String(saved.state || '').toUpperCase(); + if (savedState !== 'GENERATING' && savedState !== 'CYCLING') return false; + + const serverSession = findActiveSessionSummary(saved, activeSessions); + if (Array.isArray(activeSessions) && activeSessions.length > 0 && !serverSession) { + return false; + } + + currentSessionId = saved.id; + applySavedSessionMeta(serverSession); + applySavedSessionMeta(saved); + + expectedVariants = Number(saved.expected || serverSession?.expectedVariants || selectedCount || 0); + arrivedVariants = Number(saved.arrived || serverSession?.arrivedVariants || 0); + if (arrivedVariants <= 0 && currentPreviewFile) arrivedVariants = Number(serverSession?.expectedVariants || saved.expected || selectedCount || 0); + if (expectedVariants <= 0) expectedVariants = Number(serverSession?.expectedVariants || arrivedVariants || selectedCount || 0); + visibleVariant = clampVariantIndex(saved.visible, arrivedVariants || expectedVariants) + || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) + || (arrivedVariants > 0 ? 1 : 0); + + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + if (variantObserver) variantObserver.disconnect(); + variantObserver = startVariantObserver(currentSessionId); + saveSession(); + queueCheckpoint(reason || 'browser_restore_without_wrapper'); + + const restoreFile = currentPreviewMode === 'svelte-component' + ? currentPreviewFile + : (currentSourceFile || currentPreviewFile); + if (restoreFile) { + injectVariantsFromSource(restoreFile, currentSessionId); + return true; + } + + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return true; + } + + function restoreFromActiveSessions(activeSessions, reason) { + const wrapper = document.querySelector('[data-impeccable-variants]'); + if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false; + if (svelteComponentSession?.sessionId === currentSessionId) return false; + return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); + } + function saveSession() { if (!currentSessionId) return; // NOTE: scrollY is stored under a separate key (writeScrollY). Storing @@ -5869,6 +7108,11 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, + pageUrl: location.pathname, + paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, }); } @@ -5898,31 +7142,33 @@ void main() { } function cleanup() { - // Hide the wrapper immediately so variants disappear. DON'T structurally - // mutate the DOM yet - HMR from the agent's source rewrite is on its way, - // and a manual replaceChild under React causes NotFoundError when the - // reconciler later tries to remove a wrapper we already removed. - // Schedule a 2s fallback that does the manual swap only if HMR hasn't - // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const cleanupSessionId = currentSessionId; - if (cleanupSessionId) { + if (svelteComponentSession?.sessionId === cleanupSessionId) { + teardownSvelteComponentSession(true); + } else if (cleanupSessionId) { + // Hide the wrapper immediately so variants disappear. DON'T structurally + // mutate the DOM yet - HMR from the agent's source rewrite is on its way, + // and a manual replaceChild under React causes NotFoundError when the + // reconciler later tries to remove a wrapper we already removed. + // Schedule a 2s fallback that does the manual swap only if HMR hasn't + // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); if (wrapper) wrapper.style.display = 'none'; - } - setTimeout(function() { - if (!cleanupSessionId) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!wrapper) return; - const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - wrapper.parentElement.replaceChild(content, wrapper); - return; + setTimeout(function() { + if (!cleanupSessionId) return; + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) return; + const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); + if (orig) { + const content = orig.firstElementChild; + if (content) { + lateWrapper.parentElement.replaceChild(content, lateWrapper); + return; + } } - } - wrapper.remove(); - }, 2000); + lateWrapper.remove(); + }, 2000); + } hideBar(); hideHighlight(); stopScrollTracking(); @@ -5931,6 +7177,7 @@ void main() { clearScrollY(); finalizeInsertSession(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5938,9 +7185,9 @@ void main() { state = 'PICKING'; } - // --------------------------------------------------------------------------- + // // Toast - // --------------------------------------------------------------------------- + // function showToast(message, duration) { if (toastEl) toastEl.remove(); @@ -5964,7 +7211,7 @@ void main() { }); toastEl.id = PREFIX + '-toast'; toastEl.textContent = message; - document.body.appendChild(toastEl); + uiAppend(toastEl); requestAnimationFrame(() => { toastEl.style.opacity = '1'; toastEl.style.transform = 'translateX(-50%) translateY(0)'; @@ -5978,22 +7225,70 @@ void main() { }, duration); } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. function resumeSession() { const wrapper = document.querySelector('[data-impeccable-variants]'); - if (!wrapper) { clearSession(); clearHandled(); return false; } + if (!wrapper) { + if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } const sessionId = wrapper.dataset.impeccableVariants; // Don't resume if this session was already accepted/discarded if (isSessionHandled(sessionId)) return false; + // Svelte component sessions can't be resumed by counting DOM children: the + // wrapper holds a single mount target, not [data-impeccable-variant] nodes, + // and a page reload unmounts every compiled variant. Counting children here + // would strand the bar in CYCLING at 0/0. If there's no live in-memory mount + // for this wrapper, it's an orphan (reload / failed mount): drop it and let + // the live-server's SSE re-inject the manifest if the session is still live. + if (wrapper.dataset.impeccablePreview === 'svelte-component' + && svelteComponentSession?.sessionId !== sessionId) { + wrapper.remove(); + if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } + + if (wrapper.dataset.impeccablePreview === 'svelte-component') { + if (!svelteComponentSession?.mountedVariant) { + return true; + } + currentSessionId = sessionId; + expectedVariants = Number(wrapper.dataset.impeccableVariantCount) + || Number(svelteComponentSession.manifest?.count) + || expectedVariants + || 1; + arrivedVariants = expectedVariants; + const saved = loadSession(); + applySavedSessionMeta(saved); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = svelteComponentSession.mountedVariant > 0 && svelteComponentSession.mountedVariant <= arrivedVariants + ? svelteComponentSession.mountedVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = resolveSvelteComponentAnchor() + || wrapper.parentElement; + state = 'CYCLING'; + hideShaderOverlay(); + showBar('cycling'); + startScrollTracking(); + refreshParamsPanel(); + saveSession(); + queueCheckpoint('browser_resumed_svelte_component'); + return true; + } + currentSessionId = sessionId; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -6002,6 +7297,7 @@ void main() { // Restore state from localStorage if available const saved = loadSession(); if (saved && saved.id === sessionId) { + applySavedSessionMeta(saved); visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0); if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; @@ -6072,9 +7368,9 @@ void main() { return true; } - // --------------------------------------------------------------------------- + // // Global bar (always visible at bottom) - // --------------------------------------------------------------------------- + // let globalBarEl = null; let globalBarBrandEl = null; @@ -6166,6 +7462,8 @@ void main() { let pageChatExpanded = false; let steerLocked = false; let steerRequestId = null; + let steerPendingMessage = ''; + let steerInputWasFocused = false; let pageChatDotsEl = null; let steerAwaitTimer = null; let voiceRecognition = null; @@ -6179,7 +7477,7 @@ void main() { const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; - const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect'; + const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect'; const GLOBAL_BAR_SECTION_GAP = 8; const GLOBAL_BAR_INNER_GAP = 2; const GLOBAL_BAR_INNER_PAD_LEFT = 2; @@ -6323,7 +7621,7 @@ void main() { const attempt = () => { steerFocusRecoverTimer = null; if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (pageHasHostTextSelection()) { steerFocusRecoverTimer = setTimeout(attempt, 120); return; @@ -6344,7 +7642,7 @@ void main() { steerFocusSuspended = true; steerFocusPauseUntil = performance.now() + STEER_PAGE_FOCUS_PAUSE_MS; pagePointerGesture = { x: e.clientX, y: e.clientY, dragged: false }; - if (pageChatInput && document.activeElement === pageChatInput) { + if (pageChatInput && activeElementDeep() === pageChatInput) { pageChatInput.blur(); } } @@ -6404,7 +7702,7 @@ void main() { pickActive, pageChatReady: !!pageChatInput, pageChatExpanded, - active: steerFocusTargetLabel(document.activeElement), + active: steerFocusTargetLabel(activeElementDeep()), shouldSteer: shouldFocusSteerChat(), ...(extra || {}), }); @@ -6423,26 +7721,26 @@ void main() { function focusConfigureInput(reason) { steerFocusLog('focusConfigureInput', { reason }); const inputId = configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input'; - const input = document.getElementById(inputId); + const input = uiGetById(inputId); if (!input) { steerFocusLog('focusConfigureInput missing', { reason }); return; } setTimeout(() => { - const before = document.activeElement; + const before = activeElementDeep(); input.focus(); steerFocusLog('focusConfigureInput result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== input, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== input, }); }, 60); } function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; - const focused = document.activeElement === pageChatInput; + const focused = activeElementDeep() === pageChatInput; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; const P = pageChatPalette(); pageChatEl.style.borderColor = steerLocked @@ -6476,15 +7774,15 @@ void main() { } syncPageChatVisual(); pageChatInput.style.pointerEvents = 'auto'; - const before = document.activeElement; + const before = activeElementDeep(); try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== pageChatInput, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== pageChatInput, }); } @@ -6515,6 +7813,37 @@ void main() { return wrap; } + function keepSteerPointerInside(e, opts = {}) { + e.stopPropagation(); + if (opts.preventDefault !== false) e.preventDefault(); + } + + function preparePageChatInputForTyping() { + if (!pageChatEl || !pageChatInput) return false; + pageChatExpanded = true; + pageChatEl.dataset.expanded = 'true'; + pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; + pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return true; + } + + function focusPageChatInput(reason) { + if (!preparePageChatInputForTyping() || steerLocked) return false; + try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } + const focused = activeElementDeep() === pageChatInput; + if (focused) steerInputWasFocused = true; + syncPageChatFocusRing(); + return focused; + } + function clearSteerAwaitTimer() { if (steerAwaitTimer) { clearTimeout(steerAwaitTimer); @@ -6528,6 +7857,7 @@ void main() { if (!steerLocked || steerRequestId !== id) return; unlockSteerChat({ error: 'Steer timed out waiting for the agent. Check that live-poll is running and replies with steer_done.', + restoreMessage: steerPendingMessage, }); }, STEER_AWAIT_TIMEOUT_MS); } @@ -6538,19 +7868,12 @@ void main() { steerLocked = true; pageChatEl.dataset.processing = 'true'; pageChatInput.disabled = true; - pageChatInput.value = ''; - pageChatInput.blur(); + preparePageChatInputForTyping(); if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = true; pageChatVoiceBtn.style.display = 'none'; } - pageChatExpanded = false; - pageChatEl.dataset.expanded = 'false'; - pageChatEl.style.width = PAGE_CHAT_PROCESSING_W; pageChatEl.style.cursor = 'default'; - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; pageChatInput.style.pointerEvents = 'none'; if (pageChatHint) { pageChatHint.style.display = 'none'; @@ -6568,17 +7891,26 @@ void main() { function unlockSteerChat(opts) { clearSteerAwaitTimer(); + const restoreMessage = typeof opts?.restoreMessage === 'string' ? opts.restoreMessage : ''; + const keepExpanded = Boolean(opts?.error && restoreMessage); steerLocked = false; + const completedId = steerRequestId; steerRequestId = null; if (!pageChatEl) return; pageChatEl.dataset.processing = 'false'; pageChatEl.removeAttribute('aria-busy'); pageChatEl.setAttribute('aria-label', 'Steer the page'); - pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W; + pageChatExpanded = keepExpanded; + pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false'; + pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W; pageChatEl.style.cursor = 'pointer'; if (pageChatInput) { pageChatInput.disabled = false; - pageChatInput.value = ''; + pageChatInput.value = keepExpanded ? restoreMessage : ''; + pageChatInput.style.width = keepExpanded ? '' : '0'; + pageChatInput.style.padding = keepExpanded ? '0 6px' : '0'; + pageChatInput.style.opacity = keepExpanded ? '1' : '0'; + pageChatInput.style.pointerEvents = 'auto'; } if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = false; @@ -6586,18 +7918,28 @@ void main() { } if (pageChatHint) { pageChatHint.textContent = 'Steer'; - pageChatHint.style.display = ''; - pageChatHint.style.visibility = ''; + pageChatHint.style.display = keepExpanded ? 'none' : ''; + pageChatHint.style.visibility = keepExpanded ? 'hidden' : ''; + pageChatHint.style.opacity = keepExpanded ? '0' : '1'; } if (pageChatDotsEl?.parentNode) { pageChatDotsEl.remove(); pageChatDotsEl = null; } + steerPendingMessage = keepExpanded ? restoreMessage : ''; + steerInputWasFocused = false; syncPageChatChrome(); syncPageChatFocusRing(); if (opts?.error) showToast(String(opts.error), 5000); else if (opts?.message) showToast(String(opts.message), 4000); - syncPageChatFocus('steer-unlock'); + if (completedId) { + sendSteerCheckpoint(completedId, opts?.error ? 'steer_error' : 'steer_done', { + message: opts?.message || opts?.error || '', + file: opts?.file || '', + }); + } + if (keepExpanded) focusPageChatInput('steer-error-restore'); + else syncPageChatFocus('steer-unlock'); } function steerSpeechRecognitionCtor() { @@ -6651,7 +7993,7 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = document.getElementById(PREFIX + '-configure-voice'); + const voiceBtn = uiGetById(PREFIX + '-configure-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -6784,7 +8126,7 @@ void main() { } function configureVoiceContext() { - const input = document.getElementById( + const input = uiGetById( configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input', ); return { @@ -6819,26 +8161,37 @@ void main() { if (!text || steerLocked) return; const id = id8(); steerRequestId = id; + steerPendingMessage = text; + if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); lockSteerChat(); scheduleSteerAwaitTimeout(id); + sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); sendEvent({ type: 'steer', id, message: text, pageUrl: location.href, }).then((res) => { - if (!res) unlockSteerChat({ error: 'Could not reach live server' }); + if (!res) { + sendSteerCheckpoint(id, 'steer_send_failed', { message: text }); + unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text }); + } }); } function maybeCompleteSteer(msg) { if (!steerRequestId || msg.id !== steerRequestId) return false; if (msg.type === 'steer_done') { - unlockSteerChat({ message: msg.message }); + unlockSteerChat({ message: msg.message, file: msg.file }); + if (msg.file && /\.svelte(?:$|\?)/.test(String(msg.file))) { + setTimeout(() => { + if (!steerLocked) showToast('Steer applied. Reload if the page has not refreshed yet.', 5000); + }, 4500); + } return true; } if (msg.type === 'error') { - unlockSteerChat({ error: msg.message || 'Steer failed' }); + unlockSteerChat({ error: msg.message || 'Steer failed', restoreMessage: steerPendingMessage }); return true; } return false; @@ -6847,21 +8200,10 @@ void main() { function expandPageChat(opts) { const focus = !opts || opts.focus !== false; if (!pageChatEl || !pageChatInput || steerLocked) return; - pageChatExpanded = true; - pageChatEl.dataset.expanded = 'true'; - pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; - pageChatEl.style.cursor = 'text'; - if (pageChatHint) { - pageChatHint.style.display = 'none'; - pageChatHint.style.opacity = '0'; - } - pageChatInput.style.width = ''; - pageChatInput.style.padding = '0 6px'; - pageChatInput.style.opacity = '1'; - pageChatInput.style.pointerEvents = 'auto'; + preparePageChatInputForTyping(); syncPageChatChrome(); syncPageChatFocusRing(); - if (focus) pageChatInput.focus(); + if (focus) focusPageChatInput('expand-page-chat'); } function collapsePageChat(opts) { @@ -6878,7 +8220,7 @@ void main() { } else { pageChatInput.style.pointerEvents = 'auto'; } - if (pageChatHint && document.activeElement !== pageChatInput) { + if (pageChatHint && activeElementDeep() !== pageChatInput) { pageChatHint.style.display = ''; pageChatHint.style.opacity = '1'; } @@ -6952,7 +8294,7 @@ void main() { pageChatEl.appendChild(pageChatInput); pageChatEl.appendChild(pageChatVoiceBtn); - if (!document.getElementById(PREFIX + '-page-chat-style')) { + if (!uiGetById(PREFIX + '-page-chat-style')) { const s = document.createElement('style'); s.id = PREFIX + '-page-chat-style'; s.textContent = @@ -6966,23 +8308,34 @@ void main() { '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } - pageChatEl.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { + keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat(); + expandPageChat({ focus: false }); + focusPageChatInput('page-chat-click'); }); - pageChatVoiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); + pageChatVoiceBtn.addEventListener('mousedown', keepSteerPointerInside); pageChatVoiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); + keepSteerPointerInside(e); if (steerLocked) return; toggleSteerVoice(); }); + pageChatInput.addEventListener('pointerdown', keepSteerPointerInside); + pageChatInput.addEventListener('mousedown', keepSteerPointerInside); + pageChatInput.addEventListener('click', (e) => { + keepSteerPointerInside(e); + if (!steerLocked) focusPageChatInput('page-chat-input-click'); + }); + pageChatInput.addEventListener('input', () => { syncPageChatVisual(); }); @@ -6995,7 +8348,7 @@ void main() { syncPageChatFocusRing(); setTimeout(() => { if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (!pageChatInput.value.trim()) collapsePageChat(); scheduleSteerFocusRecover('steer-blur-recover'); }, 120); @@ -7039,7 +8392,7 @@ void main() { globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false'; globalBarBrandEl.setAttribute('aria-label', connected ? 'Impeccable live mode' - : 'Impeccable live mode: agent not polling'); + : 'Impeccable live mode - agent not polling'); globalBarBrandEl.removeAttribute('title'); globalBarBrandEl.style.cursor = connected ? 'default' : 'help'; const mark = globalBarBrandEl.querySelector('[data-brand-mark]'); @@ -7077,7 +8430,7 @@ void main() { }); agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip'; agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP; - document.body.appendChild(agentPollTooltipEl); + uiAppend(agentPollTooltipEl); return agentPollTooltipEl; } @@ -7131,7 +8484,7 @@ void main() { // Custom focus-visible for bar buttons. Browser default is a heavy // blue ring that looks jarring on the dark capsule. Replace with a // soft accent-tinted inner ring that respects the bar's palette. - if (!document.getElementById(PREFIX + '-bar-focus-style')) { + if (!uiGetById(PREFIX + '-bar-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-bar-focus-style'; s.textContent = @@ -7143,7 +8496,7 @@ void main() { '@keyframes impeccable-agent-dot { 0%, 100% { opacity: 0.45; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1); } }' + '#' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: impeccable-agent-dot 1.4s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: none; opacity: 0.9; } }'; - document.head.appendChild(s); + uiAppendStyle(s); } globalBarEl = el('div', { @@ -7176,7 +8529,7 @@ void main() { brand.id = PREFIX + '-global-bar-brand'; brand.dataset.agentConnected = 'false'; brand.setAttribute('role', 'img'); - brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling'); + brand.setAttribute('aria-label', 'Impeccable live mode - agent not polling'); const brandMark = el('span', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -7211,7 +8564,7 @@ void main() { inner.id = PREFIX + '-global-bar-inner'; globalBarEl.appendChild(inner); - // --- button factory: icon-only at rest, label slides in on hover/active --- + // Button factory: icon-only at rest, label slides in on hover/active. function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { const b = el('button', { position: 'relative', @@ -7506,6 +8859,7 @@ void main() { color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', }); + exitBtn.id = PREFIX + '-exit'; exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = 'oklch(58% 0.15 35)'; exitBtn.style.background = P.exitHover; }); @@ -7530,8 +8884,8 @@ void main() { try { window.focus(); } catch { /* in-app preview may block */ } }, true); - document.body.appendChild(pendingDockEl); - document.body.appendChild(globalBarEl); + uiAppend(pendingDockEl); + uiAppend(globalBarEl); defangOutsideHandlers(pendingDockEl); defangOutsideHandlers(globalBarEl); @@ -7553,11 +8907,11 @@ void main() { } function updateGlobalBarState() { - const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); - const detectBadge = document.getElementById(PREFIX + '-detect-badge'); - const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); - const insertToggle = document.getElementById(PREFIX + '-insert-toggle'); - const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const detectToggle = uiGetById(PREFIX + '-detect-toggle'); + const detectBadge = uiGetById(PREFIX + '-detect-badge'); + const pickToggle = uiGetById(PREFIX + '-pick-toggle'); + const insertToggle = uiGetById(PREFIX + '-insert-toggle'); + const designToggle = uiGetById(PREFIX + '-design-toggle'); const theme = globalBarEl?.dataset.theme || 'light'; const P = barPaletteForTheme(theme); @@ -7751,8 +9105,9 @@ void main() { pendingApplyInFlight = false; } if (globalBarEl) { - globalBarEl.style.transform = 'translateY(100%)'; - setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300); + globalBarEl.style.transition = 'none'; + globalBarEl.remove(); + globalBarEl = null; } pageChatEl = null; pageChatInput = null; @@ -7765,6 +9120,7 @@ void main() { if (barEl) { barEl.remove(); barEl = null; } if (pickerEl) { pickerEl.remove(); pickerEl = null; } if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; } + if (editBadgeProxyRoot) { editBadgeProxyRoot.remove(); editBadgeProxyRoot = null; editBadgeProxyByTarget = new Map(); } if (evtSource) { evtSource.close(); evtSource = null; } document.removeEventListener('mousemove', handleMouseMove, true); document.removeEventListener('click', handleClick, true); @@ -7777,9 +9133,9 @@ void main() { console.log('[impeccable] Live mode exited.'); } - // --------------------------------------------------------------------------- + // // Design System Panel - visualizes the project's .impeccable/design.json sidecar - // --------------------------------------------------------------------------- + // const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; const DESIGN_PANEL_WIDTH = 440; @@ -7847,7 +9203,7 @@ void main() { root.className = 'root'; designShadow.appendChild(root); - document.body.appendChild(designHost); + uiAppend(designHost); // The host is pointer-events: none; the panel inside the shadow DOM // manages its own auto/none. Events bubble through the shadow boundary, // so attaching here silences host-page outside-interaction handlers @@ -7889,7 +9245,7 @@ void main() { .root * { box-sizing: border-box; } button { font: inherit; color: inherit; } - /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + /* Panel shell: chrome matches the bar; body canvas stays neutral */ .panel { position: fixed; top: 12px; bottom: 72px; right: 12px; width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); @@ -7955,7 +9311,7 @@ void main() { .panel-body::-webkit-scrollbar { width: 8px; } .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } - /* --- States --- */ + /* States */ .empty, .loading, .error { margin: 16px 4px; padding: 28px 20px; text-align: center; @@ -7966,7 +9322,7 @@ void main() { .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; } .error { color: oklch(45% 0.15 25); } - /* --- Stale hint --- */ + /* Stale hint */ .stale { display: flex; align-items: center; gap: 8px; margin: 8px 4px 12px; @@ -7979,7 +9335,7 @@ void main() { .stale-text { flex: 1; min-width: 0; } .stale-text strong { color: ${DP.ink}; font-weight: 600; } - /* --- Parsed-md fallback banner --- */ + /* Parsed-md fallback banner */ .parsed-md-cta { margin: 8px 4px 14px; padding: 14px 16px; @@ -7991,7 +9347,7 @@ void main() { .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; } .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; } - /* --- Tile primitives --- */ + /* Tile primitives */ .tile { position: relative; background: ${DP.tile}; @@ -8010,7 +9366,7 @@ void main() { } .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; } - /* --- Color tile --- */ + /* Color tile */ .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; } .c-tile:hover { transform: translateY(-1px); } .c-hero { @@ -8025,7 +9381,7 @@ void main() { .c-ramp > span { flex: 1; } .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Type tile --- */ + /* Type tile */ .t-tile { } .t-specimen { margin: 4px 0 6px; @@ -8035,7 +9391,7 @@ void main() { .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; } .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Shadow tile --- */ + /* Shadow tile */ .s-tile { } .s-surface { height: 60px; margin: 8px 2px 10px; @@ -8045,14 +9401,14 @@ void main() { .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; } .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; } - /* --- Radii strip --- */ + /* Radii strip */ .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; } .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; } .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); } .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; } .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; } - /* --- Component tile (hosts live primitives) --- */ + /* Component tile (hosts live primitives) */ .cmp-tile { } .cmp-stage { margin: 12px -4px 0; @@ -8066,7 +9422,7 @@ void main() { .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; } .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; } - /* --- Collapsible --- */ + /* Collapsible */ .coll { margin: 0 4px 8px; background: ${DP.tile}; @@ -8131,7 +9487,7 @@ void main() { .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; } .coll .overview-body li { margin-bottom: 3px; } - /* --- raw tab markdown (unchanged layout, neutralized palette) --- */ + /* raw tab markdown (unchanged layout, neutralized palette) */ .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; } .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; } .md h1 { font-size: 18px; } @@ -8303,7 +9659,7 @@ void main() { return box; } - // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 --- + // Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 function renderDesignVisual(body, parsed, sidecar) { const frontmatter = parsed?.frontmatter || {}; @@ -8667,7 +10023,7 @@ void main() { return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components'); } - // --- Collapsibles --------------------------------------------------------- + // Collapsibles. function buildCollapsible(key, label, count) { const wrap = document.createElement('div'); @@ -8775,7 +10131,7 @@ void main() { return s.replace(/\s+#.*$/, '').trim(); } - // --- Raw tab: minimal markdown renderer (subset) -------------------------- + // Raw tab: minimal markdown renderer (subset) function renderRawTab(body, md) { const wrap = document.createElement('div'); @@ -8908,9 +10264,9 @@ void main() { } catch { /* ignore */ } } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // function init() { try { history.scrollRestoration = 'manual'; } catch {} diff --git a/.qoder/skills/impeccable/scripts/live-completion.mjs b/.qoder/skills/impeccable/scripts/live-completion.mjs index 86b637fff..986773066 100644 --- a/.qoder/skills/impeccable/scripts/live-completion.mjs +++ b/.qoder/skills/impeccable/scripts/live-completion.mjs @@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.mode === 'error') return 'error'; + if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error'; return 'agent_done'; } diff --git a/.qoder/skills/impeccable/scripts/live-inject.mjs b/.qoder/skills/impeccable/scripts/live-inject.mjs index b9d3df41d..3a1f36e46 100644 --- a/.qoder/skills/impeccable/scripts/live-inject.mjs +++ b/.qoder/skills/impeccable/scripts/live-inject.mjs @@ -17,11 +17,38 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './impeccable-paths.mjs'; +import { + applySvelteKitLiveAdapter, + detectSvelteKitProject, + removeSvelteKitLiveAdapter, +} from './live-sveltekit-adapter.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start'; +const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end'; + +export const LIVE_IGNORE_PATTERNS = Object.freeze([ + '.impeccable/hook.cache.json', + '.impeccable/live/server.json', + '.impeccable/live/sessions/', + '.impeccable/live/previews/', + '.impeccable/live/annotations/', + '.impeccable/live/cache/', + '.impeccable/live/manual-edit-apply-transaction.json', + '.impeccable/live/manual-edit-events.jsonl', + '.impeccable/live/manual-edit-evidence/', + '.impeccable/live/pending-manual-edits.json', + '.impeccable/live/deferred-svelte-component-accepts.json', + '.impeccable-live.json', + '.impeccable-live/', + 'node_modules/.impeccable-live/', + 'src/lib/impeccable/ImpeccableLiveRoot.svelte', + 'src/lib/impeccable/__runtime.js', + 'src/lib/impeccable/[0-9a-f]*/', +]); /** * Hard-excluded directory patterns. These are NEVER user-facing pages and @@ -83,8 +110,14 @@ Output (JSON): validateConfig(config); const resolvedFiles = resolveFiles(process.cwd(), config); + const svelteKit = detectSvelteKitProject(process.cwd(), config); if (args.includes('--remove')) { + if (svelteKit) { + const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config }); + console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; @@ -110,6 +143,13 @@ Output (JSON): console.error(JSON.stringify({ ok: false, error: 'missing_port' })); process.exit(1); } + const gitIgnore = ensureLiveGitIgnores(process.cwd()); + + if (svelteKit) { + const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config }); + console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); @@ -129,10 +169,68 @@ Output (JSON): }; }); const anyInserted = results.some((r) => r.inserted); - console.log(JSON.stringify({ ok: anyInserted, port, results })); + console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results })); if (!anyInserted) process.exit(1); } +export function ensureLiveGitIgnores(cwd = process.cwd()) { + const target = resolveIgnoreTarget(cwd); + const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : ''; + const block = [ + IGNORE_MARKER_OPEN, + ...LIVE_IGNORE_PATTERNS, + IGNORE_MARKER_CLOSE, + ].join('\n'); + const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`); + + let updated; + if (markerRe.test(existing)) { + updated = existing.replace(markerRe, block); + } else { + const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n'; + updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`; + } + + if (updated !== existing) { + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + fs.writeFileSync(target.path, updated, 'utf-8'); + } + + return { + file: path.relative(cwd, target.path).split(path.sep).join('/'), + mode: target.mode, + changed: updated !== existing, + patterns: [...LIVE_IGNORE_PATTERNS], + }; +} + +function resolveIgnoreTarget(cwd) { + const gitExcludePath = resolveGitInfoExcludePath(cwd); + if (gitExcludePath) { + return { path: gitExcludePath, mode: 'git-info-exclude' }; + } + return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' }; +} + +function resolveGitInfoExcludePath(cwd) { + const dotGit = path.join(cwd, '.git'); + if (!fs.existsSync(dotGit)) return null; + + const stat = fs.statSync(dotGit); + if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude'); + if (!stat.isFile()) return null; + + const body = fs.readFileSync(dotGit, 'utf-8').trim(); + const match = body.match(/^gitdir:\s*(.+)$/i); + if (!match) return null; + const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]); + return path.join(gitDir, 'info', 'exclude'); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Expand config.files (which may contain glob patterns) into a literal list * of existing file paths relative to rootDir. Literal entries pass through; diff --git a/.qoder/skills/impeccable/scripts/live-insert.mjs b/.qoder/skills/impeccable/scripts/live-insert.mjs index 09d4d55be..0658e9914 100644 --- a/.qoder/skills/impeccable/scripts/live-insert.mjs +++ b/.qoder/skills/impeccable/scripts/live-insert.mjs @@ -21,6 +21,11 @@ import { buildCssAuthoring, buildCssSelectorPrefixExamples, } from './live-wrap.mjs'; +import { + buildSvelteComponentCssAuthoring, + scaffoldSvelteComponentInsertSession, + shouldUseSvelteComponentInjection, +} from './live-svelte-component.mjs'; const INSERT_POSITIONS = new Set(['before', 'after']); @@ -192,6 +197,41 @@ Output (JSON): const styleMode = detectStyleMode(targetFile); const isJsx = commentSyntax.open === '{/*'; const spliceIndex = computeInsertLine(startLine, endLine, position); + const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); + + if (shouldUseSvelteComponentInjection(targetFile)) { + const session = scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile: relTargetFile, + insertLine: spliceIndex + 1, + position, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + anchorLines: lines.slice(startLine, endLine + 1), + cwd: process.cwd(), + }); + console.log(JSON.stringify({ + mode: 'insert', + position, + file: session.manifestFile, + sourceFile: relTargetFile, + previewMode: 'svelte-component', + componentDir: session.componentDir, + propContract: session.propContract, + insertLine: 1, + sourceInsertLine: spliceIndex + 1, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + commentSyntax, + styleMode: 'svelte-component', + styleTag: null, + cssSelectorPrefixExamples: [], + cssAuthoring: buildSvelteComponentCssAuthoring(count), + })); + return; + } + const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? ''; @@ -216,7 +256,7 @@ Output (JSON): console.log(JSON.stringify({ mode: 'insert', position, - file: path.relative(process.cwd(), targetFile), + file: relTargetFile, insertLine: insertLine + 1, commentSyntax, styleMode: styleMode.mode, diff --git a/.qoder/skills/impeccable/scripts/live-poll.mjs b/.qoder/skills/impeccable/scripts/live-poll.mjs index fad836612..1e1259fbc 100644 --- a/.qoder/skills/impeccable/scripts/live-poll.mjs +++ b/.qoder/skills/impeccable/scripts/live-poll.mjs @@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs'; // that ceiling and loop in `pollOnce` to synthesize a long poll without // depending on the standalone undici package. export const PER_REQUEST_TIMEOUT_MS = 270_000; +export const DEFAULT_EVENT_LEASE_MS = 600_000; const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); @@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) { ? totalDeadline - Date.now() : PER_REQUEST_TIMEOUT_MS; const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); - const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); + const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`); if (res.status === 401) { const err = new Error('Authentication failed. The server token may have changed.'); @@ -317,7 +318,7 @@ Modes: Options: --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) - --file PATH Attach a source file path to the reply (generate flow) + --file PATH Attach a source file path to the reply (generate/steer flow) --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --help Show this help message diff --git a/.qoder/skills/impeccable/scripts/live-server.mjs b/.qoder/skills/impeccable/scripts/live-server.mjs index 16c8285b9..cd1091b88 100644 --- a/.qoder/skills/impeccable/scripts/live-server.mjs +++ b/.qoder/skills/impeccable/scripts/live-server.mjs @@ -42,6 +42,10 @@ import { } from './live-manual-edits-buffer.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs'; +import { + applyDeferredSvelteComponentAccepts, + removeAllSvelteComponentSessions, +} from './live-svelte-component.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated @@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; +const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2; const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); function tombstoneTimedOutApplyId(eventId, details = {}) { @@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) { return entry.event; } entry.leaseUntil = Date.now() + leaseMs; + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return entry.event; } @@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) { const acknowledged = state.pendingEvents[idx].event; state.pendingEvents.splice(idx, 1); scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return acknowledged; } +function findPendingEventById(id) { + if (!id) return null; + const entry = state.pendingEvents.find((item) => item.event?.id === id); + return entry?.event || null; +} + function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; return `live-poll.mjs --reply ${id} done --data ''`; @@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) { return summary; } +function summarizeActiveSessionForClient(snapshot = {}) { + return { + id: snapshot.id, + phase: snapshot.phase, + pageUrl: snapshot.pageUrl ?? null, + sourceFile: snapshot.sourceFile ?? null, + previewFile: snapshot.previewFile ?? null, + previewMode: snapshot.previewMode ?? null, + expectedVariants: snapshot.expectedVariants ?? 0, + arrivedVariants: snapshot.arrivedVariants ?? 0, + visibleVariant: snapshot.visibleVariant ?? null, + checkpointRevision: snapshot.checkpointRevision ?? 0, + paramValues: snapshot.paramValues || {}, + }; +} + +function activeSessionSummaries() { + if (!state.sessionStore) return []; + return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot)); +} + +function cancelQueuedAnonymousExitEvents() { + let removed = 0; + for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) { + const event = state.pendingEvents[i]?.event; + if (event?.type !== 'exit' || event.id) continue; + state.pendingEvents.splice(i, 1); + removed += 1; + } + if (removed > 0) { + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); + } + return removed; +} + function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { const canceledById = new Map(); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); @@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() { clearTimeout(state.leaseTimer); state.leaseTimer = null; } - if (state.pendingPolls.length === 0) return; const now = Date.now(); const nextLeaseUntil = state.pendingEvents .map((entry) => entry.leaseUntil || 0) @@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() { state.leaseTimer = setTimeout(() => { state.leaseTimer = null; flushPendingPolls(); - }, Math.max(0, nextLeaseUntil - now)); + broadcastAgentPollingIfChanged(); + }, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS)); } function flushPendingPolls() { @@ -1032,7 +1082,9 @@ function flushPendingPolls() { } function agentPollingConnected() { - return state.pendingPolls.length > 0; + const now = Date.now(); + return state.pendingPolls.length > 0 + || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now); } function broadcastAgentPollingIfChanged() { @@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/status') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } - const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; + const sessions = activeSessionSummaries(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', @@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/events' && req.method === 'GET') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + clearTimeout(state.exitTimer); + state.exitTimer = null; + cancelQueuedAnonymousExitEvents(); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { type: 'connected', hasProjectContext: hasProjectContext(), agentPolling: agentPollingConnected(), + activeSessions: activeSessionSummaries(), }) + '\n\n'); state.sseClients.add(res); - clearTimeout(state.exitTimer); // Keepalive: SSE comment every 30s prevents silent connection drops. const heartbeat = setInterval(() => { @@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } } + if (msg.type === 'exit') { + cleanupSvelteComponentSessionsBeforeExit(); + } if (msg.type !== 'checkpoint') { enqueueEvent(msg); } @@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) { }); } +function sessionFileMetadataFromPollReply(file) { + if (!file || typeof file !== 'string') return { file }; + const normalized = file.split(path.sep).join('/'); + const base = { file: normalized }; + if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base; + if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base; + + let full; + try { + full = path.resolve(process.cwd(), normalized); + const rel = path.relative(process.cwd(), full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base; + } catch { + return base; + } + + try { + const manifest = JSON.parse(fs.readFileSync(full, 'utf-8')); + if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base; + return { + file: String(manifest.sourceFile).split(path.sep).join('/'), + sourceFile: String(manifest.sourceFile).split(path.sep).join('/'), + previewFile: normalized, + previewMode: 'svelte-component', + }; + } catch { + return base; + } +} + function handlePollPost(req, res) { let body = ''; req.on('data', (c) => { body += c; }); @@ -1965,6 +2053,16 @@ function handlePollPost(req, res) { res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); return; } + const pendingEventBeforeAck = findPendingEventById(msg.id); + if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done' + && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'steer_done_requires_file_or_message', + hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.', + })); + return; + } const acknowledgedEvent = acknowledgePendingEvent(msg.id); let skipJournalReply = false; let existingSession = null; @@ -1987,6 +2085,7 @@ function handlePollPost(req, res) { })); return; } + const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); if (state.sessionStore && msg.id && !skipJournalReply) { try { const eventType = msg.type === 'steer_done' @@ -2001,7 +2100,10 @@ function handlePollPost(req, res) { state.sessionStore.appendEvent({ type: eventType, id: msg.id, - file: msg.file, + file: replyFileMeta.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, message: msg.message, sourceEventType: acknowledgedEvent?.type, carbonize: msg.data?.carbonize === true, @@ -2010,7 +2112,16 @@ function handlePollPost(req, res) { } flushPendingPolls(); // Forward the reply to the browser via SSE - broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + broadcast({ + type: msg.type || 'done', + id: msg.id, + message: msg.message, + file: msg.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, + data: msg.data, + }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); @@ -2023,6 +2134,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { + cleanupSvelteComponentSessionsBeforeExit(); removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; @@ -2037,6 +2149,25 @@ function shutdown() { process.exit(0); } +function cleanupSvelteComponentSessionsBeforeExit() { + try { + removeAllSvelteComponentSessions(process.cwd()); + } catch (err) { + console.warn('[impeccable] Svelte component session cleanup failed:', err.message); + } +} + +function applyLegacyDeferredAcceptsOnStartup() { + try { + const result = applyDeferredSvelteComponentAccepts(process.cwd()); + if (result.applied > 0 || result.failed > 0) { + console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result)); + } + } catch (err) { + console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({ cwd: process.cwd(), reason: 'manual_edit_server_start_recovered_abandoned_transaction', }); +applyLegacyDeferredAcceptsOnStartup(); restorePendingEventsFromStore(); pruneStaleManualApplyEvidence(process.cwd()); const portArg = args.find(a => a.startsWith('--port=')); diff --git a/.qoder/skills/impeccable/scripts/live-session-store.mjs b/.qoder/skills/impeccable/scripts/live-session-store.mjs index 7562e3d5b..5ec4d34d4 100644 --- a/.qoder/skills/impeccable/scripts/live-session-store.mjs +++ b/.qoder/skills/impeccable/scripts/live-session-store.mjs @@ -106,6 +106,8 @@ function baseSnapshot(id) { phase: 'new', pageUrl: null, sourceFile: null, + previewFile: null, + previewMode: null, expectedVariants: 0, arrivedVariants: 0, visibleVariant: null, @@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; - next.sourceFile = event.file ?? next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0); next.pendingEventSeq = null; next.pendingEvent = null; if (event.carbonize === true) { @@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { } break; case 'checkpoint': + if (COMPLETED_PHASES.has(next.phase)) { + next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null }); + break; + } if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { next.phase = event.phase ?? next.phase; next.checkpointRevision = event.revision ?? next.checkpointRevision; next.activeOwner = event.owner ?? next.activeOwner; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.visibleVariant = event.visibleVariant ?? next.visibleVariant; + next.sourceFile = event.sourceFile ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; if (event.paramValues) next.paramValues = { ...event.paramValues }; } else { next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); @@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'steer_done': next.phase = 'steer_done'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.message = event.message ?? next.message; next.pendingEventSeq = null; next.pendingEvent = null; break; @@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'complete': next.phase = 'completed'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; next.pendingEventSeq = null; next.pendingEvent = null; break; diff --git a/.qoder/skills/impeccable/scripts/live-svelte-component.mjs b/.qoder/skills/impeccable/scripts/live-svelte-component.mjs new file mode 100644 index 000000000..dc35dc0ce --- /dev/null +++ b/.qoder/skills/impeccable/scripts/live-svelte-component.mjs @@ -0,0 +1,826 @@ +/** + * Svelte live-mode component injection helpers. + * + * Variants are real .svelte components under node_modules/.impeccable-live//. + * The browser mounts them via Svelte 5 mount(); accept inlines the chosen + * variant back into the route source with props mapped to original bindings. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; + +export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live'; +export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`; +export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json'; + +const MUSTACHE_RE = /\{([^{}]+)\}/g; + +export function shouldUseSvelteComponentInjection(filePath) { + if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false; + return path.extname(filePath).toLowerCase() === '.svelte'; +} + +export function componentSessionDir(id, cwd = process.cwd()) { + return path.join(cwd, SVELTE_COMPONENT_ROOT, id); +} + +export function manifestPathForSession(id, cwd = process.cwd()) { + return path.join(componentSessionDir(id, cwd), 'manifest.json'); +} + +export function ensureRuntimeHelper(cwd = process.cwd()) { + const file = path.join(cwd, SVELTE_RUNTIME_FILE); + if (fs.existsSync(file)) return file; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8'); + return file; +} + +/** + * Extract ordered unique mustache expressions from markup (not inside ). + */ +export function extractMustacheExpressions(text) { + const expressions = []; + const seen = new Set(); + const lines = String(text || '').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('\n` + : ''; + return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n\n`; +} + +function buildInsertVariantStub(variantNum) { + return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; +} + +export function scaffoldSvelteComponentSession({ + id, + count, + sourceFile, + sourceStartLine, + sourceEndLine, + originalLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const originalMarkup = originalLines.join('\n'); + const contract = buildPropContract(extractMustacheExpressions(originalMarkup)); + const originalWithProps = substituteExprsWithProps(originalMarkup, contract); + + const manifest = { + id, + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + sourceStartLine, + sourceEndLine, + count, + propContract: contract, + originalMarkup, + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: contract, + }; +} + +export function scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile, + insertLine, + position, + anchorStartLine, + anchorEndLine, + anchorLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const anchorMarkup = (anchorLines || []).join('\n'); + const manifest = { + id, + mode: 'insert', + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + insertLine, + position, + anchorStartLine, + anchorEndLine, + originalMarkup: anchorMarkup, + anchorMarkup, + count, + propContract: [], + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: [], + }; +} + +export function findSvelteComponentManifest(id, cwd = process.cwd()) { + const direct = manifestPathForSession(id, cwd); + if (fs.existsSync(direct)) { + return readManifest(direct); + } + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return null; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(root, entry.name, 'manifest.json'); + if (!fs.existsSync(candidate)) continue; + try { + const manifest = readManifest(candidate); + if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; + } catch { /* skip */ } + } + return null; +} + +export function readManifest(manifestPath) { + const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + return { + ...data, + manifestPath, + }; +} + +export function resolveSourceFile(sourceFile, cwd = process.cwd()) { + if (!sourceFile || path.isAbsolute(sourceFile)) { + throw new Error('Invalid svelte-component source file'); + } + const full = path.resolve(cwd, sourceFile); + const rel = path.relative(cwd, full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('Svelte-component source file escapes project root'); + } + if (!fs.existsSync(full)) { + throw new Error('Svelte-component source file not found: ' + sourceFile); + } + return full; +} + +function appendCssToSvelteStyle(lines, cssLines) { + const closeIdx = findLastStyleCloseLine(lines); + const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; + if (closeIdx === -1) { + return [...lines, '', '']; + } + return [ + ...lines.slice(0, closeIdx), + ...prepared, + ...lines.slice(closeIdx), + ]; +} + +function findLastStyleCloseLine(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + if (/<\/style\s*>/.test(lines[i])) return i; + } + return -1; +} + +function bakeParamValuesInCss(cssLines, paramValues) { + if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; + return cssLines.map((line) => { + let out = line; + for (const [key, value] of Object.entries(paramValues)) { + const varName = `--p-${key}`; + out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); + } + return out; + }); +} + +function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { + const css = String((cssLines || []).join('\n')); + if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; + + const rules = parseCssRules(css); + const output = []; + for (const rule of rules) { + appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); + } + return output.join('\n') + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ''); +} + +function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { + const prelude = rule.prelude.trim(); + const body = rule.body.trim(); + if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; + + if (/^@scope\b/i.test(prelude)) { + if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; + const inner = parseCssRules(body); + for (const innerRule of inner) { + const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); + if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; + output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); + } + return; + } + + const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); + if (!rewrittenPrelude) return; + output.push(formatCssRule(rewrittenPrelude, body)); +} + +function parseCssRules(css) { + const rules = []; + const text = String(css || ''); + let i = 0; + while (i < text.length) { + while (i < text.length && /\s/.test(text[i])) i++; + const preludeStart = i; + while (i < text.length && text[i] !== '{') i++; + if (i >= text.length) break; + const prelude = text.slice(preludeStart, i).trim(); + i++; + const bodyStart = i; + let depth = 1; + let quote = null; + let comment = false; + while (i < text.length && depth > 0) { + const ch = text[i]; + const next = text[i + 1]; + if (comment) { + if (ch === '*' && next === '/') { + comment = false; + i += 2; + continue; + } + i++; + continue; + } + if (quote) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === quote) quote = null; + i++; + continue; + } + if (ch === '/' && next === '*') { + comment = true; + i += 2; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); + if (prelude) rules.push({ prelude, body }); + } + return rules; +} + +function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { + const selectors = splitSelectorList(prelude); + const rewritten = []; + for (const selector of selectors) { + const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); + if (next) rewritten.push(next); + } + return rewritten.join(', '); +} + +function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { + let out = selector.trim(); + const hasVariant = /data-impeccable-variant/.test(out); + if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; + if (hasVariant) { + out = out.replace(variantSelectorRegex(variantNum), ''); + out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); + } + + const paramResult = rewriteParamSelectors(out, paramValues); + if (!paramResult.keep) return ''; + out = paramResult.selector; + + out = out + .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') + .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') + .replace(/\s+/g, ' ') + .trim(); + + out = out.replace(/^[>+~]\s*/, '').trim(); + if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; + return out; +} + +function rewriteParamSelectors(selector, paramValues) { + let keep = true; + const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { + if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; + const actual = paramValues[key]; + if (expected != null && String(actual) !== String(expected)) { + keep = false; + return ''; + } + if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { + keep = false; + return ''; + } + return ''; + }); + return { keep, selector: next }; +} + +function splitSelectorList(prelude) { + const selectors = []; + let start = 0; + let bracket = 0; + let paren = 0; + let quote = null; + for (let i = 0; i < prelude.length; i++) { + const ch = prelude[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '[') bracket++; + else if (ch === ']') bracket = Math.max(0, bracket - 1); + else if (ch === '(') paren++; + else if (ch === ')') paren = Math.max(0, paren - 1); + else if (ch === ',' && bracket === 0 && paren === 0) { + selectors.push(prelude.slice(start, i)); + start = i + 1; + } + } + selectors.push(prelude.slice(start)); + return selectors; +} + +function selectorHasVariant(selector, variantNum) { + return variantSelectorRegex(variantNum).test(selector); +} + +function variantSelectorRegex(variantNum) { + return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); +} + +function formatCssRule(selector, body) { + return `${selector} { ${body.trim()} }`; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { + const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); + const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); + const resultBase = { + file: manifest.sourceFile, + sourceFile: manifest.sourceFile, + previewMode: 'svelte-component', + componentDir: manifest.componentDir, + carbonize: false, + }; + if (!fs.existsSync(variantPath)) { + return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; + } + + const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); + if (manifest.mode === 'insert') { + return inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, + }); + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const contract = manifest.propContract || []; + const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); + const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract) + .split('\n') + .map((line) => line.trimEnd()); + + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const start = Number(manifest.sourceStartLine) - 1; + const end = Number(manifest.sourceEndLine) - 1; + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { + return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; + } + + const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, start), + ...indentedMarkup, + ...sourceLines.slice(end + 1), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, +}) { + if (!svelteMarkupHasVisibleContent(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; + } + if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const restoredMarkup = String(markup || '') + .split('\n') + .map((line) => line.trimEnd()); + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const insertIndex = Number(manifest.insertLine) - 1; + if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { + return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; + } + + const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; + const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, insertIndex), + ...indentedMarkup, + ...sourceLines.slice(insertIndex), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function svelteMarkupHasVisibleContent(markup) { + const text = String(markup || '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 0) return true; + return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); +} + +function mergeOriginalTopLevelAttrs(markup, originalMarkup) { + const variantOpen = matchOpeningTag(markup); + const originalOpen = matchOpeningTag(originalMarkup); + if (!variantOpen || !originalOpen) return markup; + if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; + + const variantAttrs = parseAttrSegments(variantOpen.attrs); + const originalAttrs = parseAttrSegments(originalOpen.attrs); + const additions = []; + let attrs = variantOpen.attrs; + + const originalClass = originalAttrs.get('class'); + const variantClass = variantAttrs.get('class'); + if (originalClass && variantClass) { + const merged = mergeStaticClassAttr(originalClass, variantClass); + if (merged) { + attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); + variantAttrs.set('class', { ...variantClass, raw: merged }); + } + } else if (originalClass && !variantClass) { + additions.push(originalClass.raw); + } + + for (const [name, attr] of originalAttrs) { + if (name === 'class') continue; + if (!variantAttrs.has(name)) additions.push(attr.raw); + } + + if (additions.length === 0 && attrs === variantOpen.attrs) return markup; + const nextOpen = variantOpen.prefix + + variantOpen.tag + + attrs + + additions.map((attr) => ' ' + attr.trim()).join('') + + variantOpen.close; + return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); +} + +function matchOpeningTag(markup) { + const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); + if (!match) return null; + return { + raw: match[0], + prefix: match[1], + tag: match[2], + attrs: match[3] || '', + close: match[4], + index: match.index || 0, + }; +} + +function parseAttrSegments(attrs) { + const out = new Map(); + const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; + let match; + while ((match = re.exec(attrs))) { + const raw = match[0]; + const name = match[1]; + out.set(name, { + name, + raw, + start: match.index, + end: match.index + raw.length, + }); + } + return out; +} + +function mergeStaticClassAttr(originalClass, variantClass) { + const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + if (!originalValue || !variantValue) return null; + const quote = variantValue[1]; + const classes = [ + ...variantValue[2].split(/\s+/), + ...originalValue[2].split(/\s+/), + ].filter(Boolean); + return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; +} + +export function removeSvelteComponentSession(id, cwd = process.cwd()) { + const dir = componentSessionDir(id, cwd); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* non-fatal */ } +} + +export function removeAllSvelteComponentSessions(cwd = process.cwd()) { + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('__')) continue; + try { + fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + } catch { /* non-fatal */ } + } +} + +export function deferredAcceptsPath(cwd = process.cwd()) { + const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16); + return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json'); +} + +export function readDeferredAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch { + return { accepts: [] }; + } +} + +export function writeDeferredAccept(entry, cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const data = readDeferredAccepts(cwd); + data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id); + data.accepts.push({ ...entry, createdAt: new Date().toISOString() }); + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + const data = readDeferredAccepts(cwd); + const pending = Array.isArray(data.accepts) ? data.accepts : []; + const results = []; + const remaining = []; + for (const entry of pending) { + try { + const manifest = findSvelteComponentManifest(entry.id, cwd); + if (!manifest) { + results.push({ id: entry.id, ok: false, error: 'manifest not found' }); + remaining.push(entry); + continue; + } + const result = inlineSvelteComponentAccept( + manifest, + entry.variantNum, + entry.paramValues || null, + cwd, + ); + results.push({ id: entry.id, ok: result.handled !== false, result }); + if (result.handled === false) remaining.push(entry); + } catch (err) { + results.push({ id: entry.id, ok: false, error: err.message }); + remaining.push(entry); + } + } + if (remaining.length > 0) { + fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8'); + } else { + try { fs.rmSync(file, { force: true }); } catch {} + } + return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results }; +} + +export function buildSvelteComponentCssAuthoring(count) { + const variantNumbers = Array.from({ length: count }, (_, i) => i + 1); + return { + mode: 'svelte-component', + styleTag: null, + strategy: 'component-style-block', + rulePattern: '.semantic-class { ... }', + selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'), + requirements: [ + 'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).', + 'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.', + 'Put variant CSS in the component ' : '')); + if (paramValues && Object.keys(paramValues).length > 0) { + lines.push( + bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close, + ); + } + lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + lines.push(bodyIndent + '
'); + lines.push(...bodyRestored); + lines.push(bodyIndent + '
'); + }; + + if (isJsx) { + const wrapperStyle = 'style={{ display: "contents" }}'; + lines.push(indent + '
'); + pushCarbonizeBody(indent + ' '); + lines.push(indent + '
'); + } else { + pushCarbonizeBody(indent); + } + + return lines; +} + +function reindentContent(contentLines, fromIndent, toIndent) { + return contentLines.map((line) => { + if (line.trim() === '') return ''; + if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length); + return toIndent + line.trimStart(); + }); +} + function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; @@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const needsCarbonize = !!(cssContent || hasHelperAttrs); - // Build the replacement const restored = deindentContent(variantContent, indent); - const replacement = []; - - if (cssContent) { - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); - // JSX targets need the CSS body wrapped in a template literal so that the - // `{` and `}` in CSS rules don't get parsed as JSX expressions. - replacement.push(indent + '' : '')); - if (paramValues && Object.keys(paramValues).length > 0) { - // Preserve the user's knob positions for the carbonize-cleanup agent - // to bake into the final CSS when it collapses scoped rules. - replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); - } - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); - } - - // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the - // carbonize CSS block working visually by re-wrapping the accepted content - // in a data-impeccable-variant="N" div with `display: contents` (so layout - // isn't affected). The carbonize agent strips this attribute + wrapper when - // it moves the CSS to a proper stylesheet. - // - // Style attribute syntax has to follow the host file's flavor — JSX files - // need the object form, otherwise React 19 throws "Failed to set indexed - // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. - if (cssContent) { - const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; - replacement.push(indent + '
'); - replacement.push(...restored); - replacement.push(indent + '
'); - } else { - replacement.push(...restored); - } + const replacement = buildCarbonizeReplacement({ + indent, + commentSyntax, + isJsx, + id, + variantNum, + cssContent, + paramValues, + restored, + }); const newLines = [ ...lines.slice(0, replaceRange.start), @@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; } +function readSourceShadowPreviewMeta(content, id) { + const escaped = escapeRegExp(id); + const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>'); + const match = String(content || '').match(wrapperRe); + if (!match) return null; + const tag = match[0]; + if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null; + const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file'); + const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start')); + const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end')); + if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null; + return { sourceFile, sourceStartLine, sourceEndLine }; +} + +function readHtmlAttr(tag, name) { + const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1')); + if (!match) return null; + return decodeHtmlAttr(match[2]); +} + +function decodeHtmlAttr(value) { + return String(value || '') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- @@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts }; diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index f609ac847..d7c580e2f 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -26,9 +26,9 @@ return; } - // --------------------------------------------------------------------------- + // // Design tokens - // --------------------------------------------------------------------------- + // // Brand kinpaku (gold) is pinned to the site's neo-kinpaku tokens // (see site/styles/kinpaku-tokens.css) so Accept / knobs / cycle-dots / @@ -115,19 +115,48 @@ { value: 'overdrive', label: 'Overdrive' }, ]; - // --------------------------------------------------------------------------- + const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions']; + const LIVE_UI_SURFACES = [ + { 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'] }, + { key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] }, + { key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-configure-input-wrap', PREFIX + '-input', PREFIX + '-configure-voice'] }, + { key: 'action-picker', ids: [PREFIX + '-picker'] }, + { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, + { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, + { key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] }, + { key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] }, + { key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] }, + { 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'] }, + { key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] }, + { key: 'design-system-panel', ids: [PREFIX + '-design-host'] }, + { key: 'toasts-and-errors', ids: [PREFIX + '-toast'] }, + { key: 'css-isolation-boundary', ids: [PREFIX + '-root'] }, + ]; + const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))]; + + // // State - // --------------------------------------------------------------------------- + // let state = 'IDLE'; let hoveredElement = null; let selectedElement = null; let currentSessionId = null; - let pendingAcceptedSession = null; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; + let svelteComponentSession = null; + let svelteRuntimePromise = null; + let pendingSvelteComponentRetryObserver = null; + let currentSourceFile = null; + let currentPreviewFile = null; + let currentPreviewMode = null; + let recoveryWaitingForAnchor = false; + let pendingAcceptedSession = null; let variantObserver = null; + let variantSelectionInFlight = false; + let variantSelectionPromise = null; + let recoveringEmptyCycling = false; let hasProjectContext = false; let selectedAction = 'impeccable'; let selectedCount = 3; @@ -175,14 +204,17 @@ let highlightEl = null; let tooltipEl = null; let barEl = null; + let barHideSeq = 0; let pickerEl = null; let toastEl = null; let scrollRaf = null; let editBadgeEl = null; + let editBadgeProxyRoot = null; + let editBadgeProxyByTarget = new Map(); - // --------------------------------------------------------------------------- + // // Helpers - // --------------------------------------------------------------------------- + // function own(el) { return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); @@ -204,8 +236,105 @@ return s; } + function rectIsUsableAnchor(rect) { + return !!rect && rect.width > 0.5 && rect.height > 0.5; + } + + function makeFrozenAnchor(el) { + if (!el || !el.getBoundingClientRect) return null; + const r = el.getBoundingClientRect(); + if (!rectIsUsableAnchor(r)) return null; + const rect = { + x: r.x, y: r.y, + top: r.top, left: r.left, + right: r.right, bottom: r.bottom, + width: r.width, height: r.height, + }; + return { + __impeccableFrozenAnchor: true, + tagName: el.tagName || 'DIV', + id: el.id || '', + classList: el.classList ? [...el.classList] : [], + hasAttribute: () => false, + getBoundingClientRect: () => rect, + }; + } + function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } + function cssId(id) { + if (window.CSS?.escape) return CSS.escape(id); + return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); + } + + function liveUiRoot() { + const root = window.__IMPECCABLE_LIVE_UI_ROOT__; + if (root && typeof root.appendChild === 'function') return root; + return document.body; + } + + function uiAppend(el) { + liveUiRoot().appendChild(el); + return el; + } + + function uiAppendStyle(styleEl) { + const root = liveUiRoot(); + if (root && root !== document.body) root.appendChild(styleEl); + else document.head.appendChild(styleEl); + return styleEl; + } + + function uiGetById(id) { + const root = liveUiRoot(); + if (root?.getElementById) { + const found = root.getElementById(id); + if (found) return found; + } + if (root?.querySelector) { + const found = root.querySelector('#' + cssId(id)); + if (found) return found; + } + return document.getElementById(id); + } + + function activeElementDeep() { + let active = document.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; + return active; + } + + window.__IMPECCABLE_LIVE_CHROME_CORE__ = { + version: 1, + adapter: window.__IMPECCABLE_LIVE_ADAPTER__ || 'dom', + mountContract: LIVE_CHROME_MOUNT_CONTRACT, + surfaces: LIVE_UI_SURFACES, + componentIds: LIVE_UI_COMPONENT_IDS, + root: liveUiRoot, + append: uiAppend, + appendStyle: uiAppendStyle, + getById: uiGetById, + activeElementDeep, + debugState: () => ({ + state, + currentSessionId, + expectedVariants, + arrivedVariants, + visibleVariant, + savedSession: loadSession(), + sourceFile: currentSourceFile, + previewFile: currentPreviewFile, + previewMode: currentPreviewMode, + barText: barEl?.textContent || null, + barConnected: !!barEl?.isConnected, + hasSvelteComponentSession: !!svelteComponentSession, + mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0, + pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver, + recoveryWaitingForAnchor, + evtSourceReadyState: evtSource ? evtSource.readyState : null, + }), + }; + // Modal-aware chrome: keep our floating UI clickable inside Radix / // Headless UI / vaul portals. // @@ -245,9 +374,9 @@ rootEl.addEventListener('focusin', stop); } - // --------------------------------------------------------------------------- + // // Highlight overlay - // --------------------------------------------------------------------------- + // function initHighlight() { highlightEl = document.createElement('div'); @@ -259,7 +388,7 @@ transition: HIGHLIGHT_TRANSITION, display: 'none', opacity: '0', }); - document.body.appendChild(highlightEl); + uiAppend(highlightEl); tooltipEl = document.createElement('div'); tooltipEl.id = PREFIX + '-tooltip'; @@ -273,7 +402,7 @@ letterSpacing: '0.02em', transition: TOOLTIP_TRANSITION, }); - document.body.appendChild(tooltipEl); + uiAppend(tooltipEl); } function showHighlight(el) { @@ -310,7 +439,7 @@ if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; } } - // --------------------------------------------------------------------------- + // // Annotation overlay (comment pins + kinpaku strokes) // // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned @@ -318,7 +447,7 @@ // drag) drops a comment pin; drag paints a kinpaku SVG stroke. All coords // are stored in element-local CSS px so they survive scroll / resize and // correlate directly with the captured PNG. - // --------------------------------------------------------------------------- + // const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it @@ -397,7 +526,7 @@ annotOverlayEl.addEventListener('pointermove', onAnnotMove); annotOverlayEl.addEventListener('pointerup', onAnnotUp); annotOverlayEl.addEventListener('pointercancel', onAnnotUp); - document.body.appendChild(annotOverlayEl); + uiAppend(annotOverlayEl); // Modal-host friendliness: pointer-events is already 'auto' on this // overlay; we only need to silence the host's outside-interaction // listeners. Don't override pointer-events here (the overlay toggles @@ -828,9 +957,9 @@ return wrap; } - // --------------------------------------------------------------------------- + // // Element context extraction - // --------------------------------------------------------------------------- + // function stripManualEditRuntimeState(root) { if (!root || root.nodeType !== 1) return; @@ -971,9 +1100,9 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } - // --------------------------------------------------------------------------- + // // The Bar - one floating element, three modes - // --------------------------------------------------------------------------- + // // Contextual-bar palette. Cached at init so every build*Row reads a // consistent set of colors; detectPageTheme runs once rather than on every @@ -1006,7 +1135,7 @@ padding: '6px', maxWidth: '520px', minWidth: '320px', }); - document.body.appendChild(barEl); + uiAppend(barEl); defangOutsideHandlers(barEl); } @@ -1041,6 +1170,8 @@ } function showBar(mode) { + barHideSeq += 1; + if (mode === 'cycling' && !ensureCyclingRenderable('show-bar')) return; barEl.innerHTML = ''; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); @@ -1058,11 +1189,12 @@ function hideBar() { if (!barEl) return; + const hideSeq = ++barHideSeq; stopVoice({ suppressSubmit: true }); if (configureKind === 'insert') clearInsertPicking(); barEl.style.opacity = '0'; barEl.style.transform = 'translateY(6px)'; - setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); + setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); if (state === 'EDITING') restoreInlineEditDrafts(); @@ -1071,6 +1203,7 @@ function updateBarContent(mode) { if (!barEl || barEl.style.display === 'none') return; + if (mode === 'cycling' && !ensureCyclingRenderable('update-bar')) return; barEl.innerHTML = ''; // Reset bar styling to the kinpaku picker palette barEl.style.background = BP.surface; @@ -1090,13 +1223,13 @@ syncPageChatFocus('update-bar-content'); } - // --- Configure row --- + // Configure row function syncConfigureInputChrome() { - const wrap = document.getElementById(PREFIX + '-configure-input-wrap'); - const input = document.getElementById(PREFIX + '-input'); + const wrap = uiGetById(PREFIX + '-configure-input-wrap'); + const input = uiGetById(PREFIX + '-input'); if (!wrap || !input) return; - const focused = document.activeElement === input; + const focused = activeElementDeep() === input; wrap.dataset.inputFocused = focused ? 'true' : 'false'; wrap.dataset.voiceListening = (voiceListening && voiceCtx?.mode === 'configure') ? 'true' : 'false'; wrap.style.borderColor = (voiceListening && voiceCtx?.mode === 'configure') @@ -1104,7 +1237,7 @@ : (focused ? BP.accentSoft : BP.hairline); } - // --- Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) --- + // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) function detectInsertAxisFromStyle(style) { const display = style?.display || 'block'; @@ -1377,7 +1510,7 @@ display: 'none', opacity: '0.9', }); - document.body.appendChild(insertLineEl); + uiAppend(insertLineEl); defangOutsideHandlers(insertLineEl); return insertLineEl; } @@ -1440,6 +1573,10 @@ /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { + if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor) return anchor; + } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (wrapper) { @@ -1557,6 +1694,11 @@ positionBar(); } + function showOrUpdateCyclingBar() { + if (barEl && barEl.style.display !== 'none') updateBarContent('cycling'); + else showBar('cycling'); + } + function buildPlaceholderResizeHandles() { if (!placeholderResizeLayerEl) return; placeholderResizeLayerEl.innerHTML = ''; @@ -1665,7 +1807,7 @@ } function isInsertCreateEnabled(btn) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); + btn = btn || uiGetById(PREFIX + '-insert-create'); return !!btn && btn.getAttribute('aria-disabled') !== 'true'; } @@ -1691,7 +1833,7 @@ lineHeight: '1.35', }); insertCreateTooltipEl.id = PREFIX + '-insert-create-tooltip'; - document.body.appendChild(insertCreateTooltipEl); + uiAppend(insertCreateTooltipEl); return insertCreateTooltipEl; } @@ -1723,8 +1865,8 @@ } function syncInsertCreateButton(btn, input) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); - input = input || document.getElementById(PREFIX + '-insert-input'); + btn = btn || uiGetById(PREFIX + '-insert-create'); + input = input || uiGetById(PREFIX + '-insert-input'); if (!btn || !input) return; const gate = insertCreateGateState(input); const ok = canCreateInsert(gate); @@ -1833,7 +1975,7 @@ voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!document.getElementById(PREFIX + '-configure-input-style')) { + if (!uiGetById(PREFIX + '-configure-input-style')) { const s = document.createElement('style'); s.id = PREFIX + '-configure-input-style'; s.textContent = @@ -1842,7 +1984,7 @@ '#' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: impeccable-configure-voice-pulse 1.1s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-configure-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } input.addEventListener('focus', () => syncConfigureInputChrome()); @@ -1949,6 +2091,9 @@ transition: 'border-color 0.15s ease', }); inputWrap.id = PREFIX + '-insert-input-wrap'; + inputWrap.addEventListener('pointerdown', (e) => e.stopPropagation()); + inputWrap.addEventListener('mousedown', (e) => e.stopPropagation()); + inputWrap.addEventListener('click', (e) => e.stopPropagation()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; @@ -1984,6 +2129,12 @@ voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; input.addEventListener('input', () => syncInsertCreateButton()); + input.addEventListener('pointerdown', (e) => e.stopPropagation()); + input.addEventListener('mousedown', (e) => e.stopPropagation()); + input.addEventListener('click', (e) => { + e.stopPropagation(); + try { input.focus({ preventScroll: true }); } catch { input.focus(); } + }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); @@ -2049,6 +2200,7 @@ }); create.addEventListener('mouseleave', hideInsertCreateTooltip); create.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); if (controlsLocked) { showManualApplyBusyToast(); return; } if (!isInsertCreateEnabled(create)) return; @@ -2060,7 +2212,7 @@ return row; } - // --- Generating row --- + // Generating row function buildGeneratingRow() { const row = el('div', { @@ -2086,19 +2238,24 @@ }); // Variants currently arrive atomically in a single file edit, so a // per-variant counter would lie. Say what's true. - status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + expectedVariants + ' variants...' - : 'Done'; + status.textContent = recoveryWaitingForAnchor + ? 'Variants ready. Reveal the selected element to resume.' + : (arrivedVariants < expectedVariants + ? 'Generating ' + expectedVariants + ' variants...' + : 'Done'); row.appendChild(status); return row; } - // --- Cycling row --- + // Cycling row const TUNE_ICON_SVG = ''; function buildCyclingRow() { + if (!ensureCyclingRenderable('build-cycling-row')) { + return el('div', { display: 'none' }); + } const row = el('div', { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 2px', @@ -2106,6 +2263,7 @@ // Prev const prev = navBtn('\u2190'); + prev.id = PREFIX + '-variant-prev'; prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); }); if (visibleVariant <= 1) prev.style.opacity = '0.3'; row.appendChild(prev); @@ -2118,11 +2276,13 @@ fontFamily: MONO, fontSize: '11px', fontWeight: '500', color: BP.textDim, minWidth: '24px', textAlign: 'center', }); + counter.id = PREFIX + '-variant-counter'; counter.textContent = visibleVariant + '/' + arrivedVariants; row.appendChild(counter); // Next const next = navBtn('\u2192'); + next.id = PREFIX + '-variant-next'; next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); }); if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3'; row.appendChild(next); @@ -2208,9 +2368,9 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders - // --- Saving row (waiting for agent to process accept/discard) --- + // Saving row (waiting for agent to process accept/discard) function buildSavingRow() { const row = el('div', { @@ -2235,7 +2395,7 @@ return row; } - // --- Confirmed row (green success, auto-dismisses) --- + // Confirmed row (green success, auto-dismisses) function buildConfirmedRow() { const row = el('div', { @@ -2256,7 +2416,7 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders function buildDots(clickable) { const container = el('div', { @@ -2290,10 +2450,7 @@ const idx = i; dot.addEventListener('click', (e) => { e.stopPropagation(); - visibleVariant = idx; - showVariantInDOM(currentSessionId, idx); - updateSelectedElement(); - updateBarContent('cycling'); + selectVariant(idx, 'variant_changed'); }); } container.appendChild(dot); @@ -2323,13 +2480,14 @@ function el(tag, styles) { const e = document.createElement(tag); + if (String(tag).toLowerCase() === 'button') e.type = 'button'; if (styles) Object.assign(e.style, styles); return e; } - // --------------------------------------------------------------------------- + // // Action picker popover - // --------------------------------------------------------------------------- + // function initActionPicker() { const P = barPaletteForTheme(detectPageTheme()); @@ -2384,16 +2542,20 @@ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent'; }); chip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + const prompt = uiGetById(PREFIX + '-input')?.value || ''; selectedAction = action.value; hideActionPicker(); updateBarContent('configure'); + const input = uiGetById(PREFIX + '-input'); + if (input && prompt) input.value = prompt; }); grid.appendChild(chip); }); pickerEl.appendChild(grid); - document.body.appendChild(pickerEl); + uiAppend(pickerEl); defangOutsideHandlers(pickerEl); // Cache the palette on the picker so toggleActionPicker's state refresh @@ -2433,7 +2595,33 @@ setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180); } - // --------------------------------------------------------------------------- + function ensureCyclingRenderable(reason) { + if (arrivedVariants > 0) { + if (visibleVariant < 1 || visibleVariant > arrivedVariants) visibleVariant = 1; + return true; + } + recoverEmptyCycling(reason); + return false; + } + + function recoverEmptyCycling(reason) { + if (recoveringEmptyCycling) return; + recoveringEmptyCycling = true; + try { + console.warn('[impeccable] Refusing to render empty variant cycling state:', reason); + const message = 'No variants were mounted. Please try again.'; + if (svelteComponentSession?.sessionId === currentSessionId) { + abortSvelteComponentInjection(currentSessionId, message); + return; + } + cleanup(); + showToast(message, 5000); + } finally { + recoveringEmptyCycling = false; + } + } + + // // Params panel (per-variant coarse controls) // // Variants may declare a parameter manifest via a JSON attribute on the @@ -2446,13 +2634,13 @@ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped // CSS can respond instantly without regeneration: // - // range / numeric toggle → CSS var (`--p-`) used via var(--p-foo, N) + // range / numeric toggle -> CSS custom property used by variant styles // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."] // // On variant switch, values reset to that variant's declared defaults. // On accept, current values are sent in the event payload so the agent // can bake them into the source-file write. - // --------------------------------------------------------------------------- + // let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide) let paramsPanelInner = null; // translating content (carries bg, padding, knobs) @@ -2507,7 +2695,7 @@ }); paramsPanelEl.appendChild(paramsPanelBody); - document.body.appendChild(paramsPanelEl); + uiAppend(paramsPanelEl); // Don't override pointer-events: the panel toggles between 'none' (closed, // click-through) and 'auto' (open) on its own. Just silence the host's // outside-interaction listeners while the panel is open. @@ -2516,14 +2704,40 @@ } + function getMountedSvelteComponentAnchor(session = svelteComponentSession) { + const el = session?.mountTargetEl?.firstElementChild || null; + if (!el || !document.body.contains(el)) return null; + return rectIsUsableAnchor(el.getBoundingClientRect()) ? el : null; + } + + function resolveSvelteComponentAnchor(session = svelteComponentSession) { + return getMountedSvelteComponentAnchor(session) + || session?.swapAnchor + || null; + } + function getVisibleVariantEl() { if (!currentSessionId) return null; + if (svelteComponentSession?.sessionId === currentSessionId) { + return resolveSvelteComponentAnchor() + || svelteComponentSession.wrapperEl + || null; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } function parseVariantParams(variantEl) { + // Svelte component variants can't carry a `data-impeccable-params` attribute: + // the compiler reads `{` inside attribute values as expression delimiters, so + // JSON-with-braces breaks the build. For that path the params live in a sidecar + // params.json keyed by variant number, loaded into the session at mount time. + if (svelteComponentSession?.sessionId === currentSessionId) { + const byVariant = svelteComponentSession.paramsByVariant || {}; + const params = byVariant[String(visibleVariant)] || byVariant[visibleVariant]; + return Array.isArray(params) ? params : []; + } if (!variantEl) return []; const raw = variantEl.getAttribute('data-impeccable-params'); if (!raw) return []; @@ -2685,11 +2899,11 @@ } } - // --------------------------------------------------------------------------- + // // Inline text editing - makes pure-text descendants of the picked element // directly contenteditable. Save stages copy edits in the live buffer; the // Apply copy edits dock later asks the AI to apply the staged batch. - // --------------------------------------------------------------------------- + // let inlineEditRows = []; let inlineEditDrafts = new Map(); @@ -2803,7 +3017,7 @@ function disableInlineEdit(opts = {}) { for (const row of inlineEditRows) { - if (document.activeElement === row.el) row.el.blur(); + if (activeElementDeep() === row.el) row.el.blur(); row.el.removeAttribute('contenteditable'); delete row.el.dataset.impeccableEditable; delete row.el.dataset.impeccableOriginalText; @@ -3133,7 +3347,7 @@ if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) { showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); } else { - showToast('Save failed: retry or cancel', 4000); + showToast('Save failed - retry or cancel', 4000); } } } @@ -3181,11 +3395,11 @@ } function ensureSpinKeyframes() { - if (document.getElementById(PREFIX + '-keyframes')) return; + if (uiGetById(PREFIX + '-keyframes')) return; const style = document.createElement('style'); style.id = PREFIX + '-keyframes'; style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }'; - document.head.appendChild(style); + uiAppendStyle(style); } function pendingApplyLabel(count) { @@ -3318,10 +3532,10 @@ closeTunePopover(); } if (barEl && barEl.style.display !== 'none' && state === 'CONFIGURING') { - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value : ''; updateBarContent('configure'); - const nextInput = document.getElementById(PREFIX + '-input'); + const nextInput = uiGetById(PREFIX + '-input'); if (nextInput) nextInput.value = prompt; } if (editBadgeEl && editBadgeEl.style.display !== 'none') { @@ -3455,19 +3669,19 @@ updatePendingCounter(remaining); if (result.failed && result.failed.length > 0) { console.warn('[impeccable] some copy edits failed:', result.failed); - showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed - see console', 5000); } else { const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0); if (n > 0) { showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); } else { console.warn('[impeccable] apply returned no verified edits:', result); - showToast('No edits applied, see console', 4000); + showToast('No edits applied - see console', 4000); } } } catch (err) { console.error('[impeccable] commit failed:', err); - showToast('Apply failed, see console', 4000); + showToast('Apply failed - see console', 4000); } finally { if (waitForSseCompletion) return; const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0; @@ -3497,7 +3711,7 @@ } } catch (err) { console.error('[impeccable] discard failed:', err); - showToast('Discard failed, see console', 4000); + showToast('Discard failed - see console', 4000); } } @@ -3645,7 +3859,7 @@ const failedCount = numberOrNull(msg.failedCount) || 0; const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0; if (failedCount > 0) { - showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000); + showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed - see console', 5000); } else if (appliedCount > 0) { showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500); } @@ -3799,9 +4013,164 @@ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); } - // --------------------------------------------------------------------------- + // // Edit content badge - floating button at element top-right to enter EDITING mode - // --------------------------------------------------------------------------- + // + + function usesShadowChromeRoot() { + const root = liveUiRoot(); + return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; + } + + function setImportantStyle(el, name, value) { + el.style.setProperty(name, value, 'important'); + } + + function initEditBadgeHitProxies() { + if (!usesShadowChromeRoot() || editBadgeProxyRoot) return; + editBadgeProxyRoot = document.createElement('div'); + editBadgeProxyRoot.id = PREFIX + '-edit-badge-hit-proxies'; + editBadgeProxyRoot.setAttribute('aria-hidden', 'true'); + const styles = { + all: 'initial', + position: 'fixed', + inset: '0', + width: '100vw', + height: '100vh', + zIndex: String(Z.toast + 1), + pointerEvents: 'none', + background: 'transparent', + overflow: 'visible', + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + document.body.appendChild(editBadgeProxyRoot); + } + + function styleEditBadgeProxy(proxy, target) { + const rect = target.getBoundingClientRect(); + const cursor = getComputedStyle(target).cursor || 'pointer'; + const styles = { + all: 'initial', + position: 'fixed', + left: rect.left + 'px', + top: rect.top + 'px', + width: rect.width + 'px', + height: rect.height + 'px', + margin: '0', + padding: '0', + border: '0', + borderRadius: '0', + background: 'transparent', + color: 'transparent', + opacity: '0.001', + pointerEvents: 'auto', + cursor, + zIndex: String(Z.toast + 2), + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(proxy, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + } + + function proxyMouseEvent(type, source, target) { + let event; + try { + event = new MouseEvent(type, { + bubbles: type !== 'mouseenter' && type !== 'mouseleave', + cancelable: true, + composed: true, + clientX: source.clientX, + clientY: source.clientY, + screenX: source.screenX, + screenY: source.screenY, + button: source.button || 0, + buttons: source.buttons || 0, + ctrlKey: source.ctrlKey, + metaKey: source.metaKey, + shiftKey: source.shiftKey, + altKey: source.altKey, + }); + target.dispatchEvent(event); + } catch {} + } + + function bindEditBadgeProxy(proxy, target) { + const stop = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + proxy.addEventListener('mouseenter', (event) => { + stop(event); + proxyMouseEvent('mouseenter', event, target); + proxyMouseEvent('mouseover', event, target); + }); + proxy.addEventListener('mouseleave', (event) => { + stop(event); + proxyMouseEvent('mouseleave', event, target); + proxyMouseEvent('mouseout', event, target); + }); + proxy.addEventListener('mousedown', (event) => { + stop(event); + target.focus?.({ preventScroll: true }); + proxyMouseEvent('mousedown', event, target); + }); + proxy.addEventListener('mouseup', (event) => { + stop(event); + proxyMouseEvent('mouseup', event, target); + }); + proxy.addEventListener('click', (event) => { + stop(event); + target.click(); + syncEditBadgeHitProxies(); + }); + } + + function editBadgeProxyTargets() { + if (!usesShadowChromeRoot() || !editBadgeEl || editBadgeEl.style.display === 'none') return []; + return [...editBadgeEl.querySelectorAll('button')].filter((target) => { + if (target.disabled) return false; + const rect = target.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return false; + const style = getComputedStyle(target); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); + } + + function syncEditBadgeHitProxies() { + if (!usesShadowChromeRoot()) { + if (editBadgeProxyRoot) editBadgeProxyRoot.remove(); + editBadgeProxyRoot = null; + editBadgeProxyByTarget = new Map(); + return; + } + initEditBadgeHitProxies(); + if (!editBadgeProxyRoot) return; + const targets = editBadgeProxyTargets(); + const active = new Set(targets); + for (const [target, proxy] of editBadgeProxyByTarget) { + if (!active.has(target) || !target.isConnected) { + proxy.remove(); + editBadgeProxyByTarget.delete(target); + } + } + for (const target of targets) { + let proxy = editBadgeProxyByTarget.get(target); + if (!proxy) { + proxy = document.createElement('button'); + proxy.type = 'button'; + proxy.tabIndex = -1; + proxy.dataset.impeccableEditBadgeProxy = 'true'; + proxy.setAttribute('aria-hidden', 'true'); + bindEditBadgeProxy(proxy, target); + editBadgeProxyRoot.appendChild(proxy); + editBadgeProxyByTarget.set(target, proxy); + } + proxy.title = target.title || target.textContent || 'Edit copy'; + styleEditBadgeProxy(proxy, target); + } + } function initEditBadge() { editBadgeEl = document.createElement('div'); @@ -3813,10 +4182,11 @@ display: 'none', userSelect: 'none', }); - document.body.appendChild(editBadgeEl); + uiAppend(editBadgeEl); + initEditBadgeHitProxies(); // Remove focus rings on edit badge buttons + contenteditable elements - if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + if (!uiGetById(PREFIX + '-edit-badge-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-edit-badge-focus-style'; s.textContent = @@ -3826,21 +4196,26 @@ '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; - document.head.appendChild(s); + uiAppendStyle(s); } } function positionEditBadge() { - if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') { + syncEditBadgeHitProxies(); + return; + } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { if (editBadgeEl) editBadgeEl.style.display = 'none'; + syncEditBadgeHitProxies(); return; } editBadgeEl.style.display = 'flex'; @@ -4047,7 +4422,7 @@ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN; } // Re-render the bar so the Tune chip picks up the active styling. - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } function closeTunePopover() { @@ -4055,13 +4430,13 @@ hideParamsPanel(); if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT; if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') { - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } } - // --------------------------------------------------------------------------- + // // Variant cycling in DOM - // --------------------------------------------------------------------------- + // function isVariantShown(el) { if (!el) return false; @@ -4081,9 +4456,42 @@ } } - function showVariantInDOM(sessionId, num) { + function scheduleCyclingBarSync(sessionId, variantNum) { + requestAnimationFrame(() => { + if (state !== 'CYCLING') return; + if (currentSessionId !== sessionId) return; + if (visibleVariant !== variantNum) return; + showOrUpdateCyclingBar(); + syncCyclingControls(); + positionBar(); + }); + } + + function syncCyclingControls() { + const shown = svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0 + ? svelteComponentSession.mountedVariant + : visibleVariant; + const counter = uiGetById(PREFIX + '-variant-counter'); + if (counter && arrivedVariants > 0) counter.textContent = shown + '/' + arrivedVariants; + const prev = uiGetById(PREFIX + '-variant-prev'); + const next = uiGetById(PREFIX + '-variant-next'); + if (prev) prev.style.opacity = shown <= 1 ? '0.3' : '1'; + if (next) next.style.opacity = shown >= arrivedVariants ? '0.3' : '1'; + if (currentSessionId && state === 'CYCLING') saveSession(); + } + + async function showVariantInDOM(sessionId, num) { + if (svelteComponentSession?.sessionId === sessionId) { + visibleVariant = num; + const mounted = await mountSvelteComponentVariant(num); + if (!mounted) return false; + updateSelectedElement(); + refreshParamsPanel(); + scheduleCyclingBarSync(sessionId, num); + return true; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); - if (!wrapper) return; + if (!wrapper) return false; for (const child of wrapper.children) { const v = child.dataset ? child.dataset.impeccableVariant : null; if (!v) continue; @@ -4093,6 +4501,378 @@ // CYCLING yet, the subsequent CYCLING transition triggers its own // refresh) and every cycle step. refreshParamsPanel(); + return true; + } + + function isSvelteComponentManifestPath(filePath) { + return String(filePath || '').endsWith('manifest.json'); + } + + function parseOriginalMarkupElement(originalMarkup) { + const parser = new DOMParser(); + const doc = parser.parseFromString('
' + originalMarkup + '
', 'text/html'); + return doc.getElementById('impeccable-anchor')?.firstElementChild || null; + } + + function findLiveElementForOriginalMarkup(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const tag = origContent.tagName.toLowerCase(); + const cls = origContent.className; + let liveEl = null; + if (origContent.id) { + liveEl = document.getElementById(origContent.id); + } else if (cls) { + const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + for (const c of candidates) { + if (c.className === cls && !own(c)) { liveEl = c; break; } + } + if (!liveEl) { + const expectedClasses = String(cls).split(/\s+/).filter(Boolean); + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } + } + } + return liveEl; + } + + function isSvelteInsertManifest(manifest) { + return manifest?.previewMode === 'svelte-component' && manifest?.mode === 'insert'; + } + + function findLiveElementForSvelteManifest(manifest) { + if (isSvelteInsertManifest(manifest)) { + const anchor = findInsertAnchorInDom(); + if (anchor?.parentElement) return anchor; + } + return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function loadSvelteRuntime(runtimeModule) { + const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js'; + const url = new URL(modulePath, location.origin).href; + if (!svelteRuntimePromise) { + svelteRuntimePromise = import(/* @vite-ignore */ url); + } + return svelteRuntimePromise; + } + + // Svelte component variants declare their params in a sidecar params.json under + // componentDir (keyed by variant number), because a `data-impeccable-params` + // attribute with JSON braces can't survive the Svelte compiler. Returns a map of + // { "1": [...params], "2": [...] }; an empty object when the agent declared none. + async function loadSvelteComponentParams(manifest) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir) return {}; + const paramsPath = dir + '/params.json'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(paramsPath); + try { + const res = await fetch(url); + if (!res.ok) return {}; + const parsed = JSON.parse(await res.text()); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out = {}; + for (const [key, value] of Object.entries(parsed)) { + if (Array.isArray(value)) out[String(key)] = value; + } + return out; + } catch { + return {}; + } + } + + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { + const contract = manifest?.propContract || []; + const values = {}; + if (!liveEl || contract.length === 0) return values; + const sourceOriginal = parseOriginalMarkupElement(manifest.originalMarkup || ''); + if (!sourceOriginal) return values; + const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl); + for (const entry of contract) { + const token = '{' + entry.expr + '}'; + values[entry.prop] = map.get(token) || ''; + } + return values; + } + + async function mountSvelteComponentVariant(variantNum) { + if (!svelteComponentSession || !variantNum) return false; + const { manifest, mountTargetEl, sessionId } = svelteComponentSession; + try { + const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement; + svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null; + const runtime = await loadSvelteRuntime(manifest.runtimeModule); + const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte'; + const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now(); + const mod = await import(/* @vite-ignore */ moduleUrl); + const Component = mod.default; + if (svelteComponentSession.mountedInstance && runtime.unmount) { + await runtime.unmount(svelteComponentSession.mountedInstance); + svelteComponentSession.mountedInstance = null; + } + svelteComponentSession.mountedInstance = runtime.mount(Component, { + target: mountTargetEl, + props: { ...svelteComponentSession.propValues }, + intro: false, + }); + svelteComponentSession.mountedVariant = variantNum; + svelteComponentSession.runtime = runtime; + if (state === 'CYCLING') syncCyclingControls(); + const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (nextAnchor) { + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(nextAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = nextAnchor; + } else { + requestAnimationFrame(() => { + if (svelteComponentSession?.sessionId !== sessionId) return; + const settledAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!settledAnchor) return; + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(settledAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = settledAnchor; + }); + } + return true; + } catch (err) { + if (svelteComponentSession?.sessionId === sessionId) { + svelteComponentSession.swapAnchor = null; + } + console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err); + return false; + } + } + + function teardownSvelteComponentSession(restoreOriginal) { + if (!svelteComponentSession) return; + const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + if (restoreOriginal && detachedOriginal && wrapperEl?.parentElement) { + wrapperEl.parentElement.replaceChild(detachedOriginal, wrapperEl); + } else if (wrapperEl?.parentElement) { + wrapperEl.remove(); + } + svelteComponentSession = null; + svelteRuntimePromise = null; + } + + function applyOriginalAttrsToSvelteAnchor(el, originalMarkup) { + if (!el || !originalMarkup) return; + const original = parseOriginalMarkupElement(originalMarkup); + if (!original || original.tagName !== el.tagName) return; + for (const attr of original.attributes) { + if (attr.name === 'class') { + for (const className of attr.value.split(/\s+/).filter(Boolean)) { + el.classList.add(className); + } + } else if (!el.hasAttribute(attr.name)) { + el.setAttribute(attr.name, attr.value); + } + } + } + + function commitAcceptedSvelteComponentToDom(sessionId) { + if (!svelteComponentSession || svelteComponentSession.sessionId !== sessionId) return false; + const { wrapperEl, runtime, mountedInstance, manifest } = svelteComponentSession; + const anchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!anchor || !wrapperEl?.parentElement) return false; + const committed = anchor.cloneNode(true); + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(committed, manifest.originalMarkup || ''); + } + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + wrapperEl.parentElement.replaceChild(committed, wrapperEl); + svelteComponentSession = null; + svelteRuntimePromise = null; + selectedElement = committed; + return true; + } + + async function injectSvelteComponentsFromManifest(manifestPath, sessionId) { + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(manifestPath); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + const manifest = JSON.parse(await res.text()); + if (manifest.id !== sessionId) return; + + const paramsByVariant = await loadSvelteComponentParams(manifest); + currentSessionId = sessionId; + expectedVariants = Number(manifest.count) || expectedVariants || 1; + rememberSessionFileMeta({ + sourceFile: manifest.sourceFile, + previewFile: manifestPath, + previewMode: 'svelte-component', + }); + if (state !== 'CYCLING') state = 'GENERATING'; + + const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { + recoveryWaitingForAnchor = false; + svelteComponentSession.paramsByVariant = paramsByVariant; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; + await mountSvelteComponentVariant(visibleVariant || 1); + state = 'CYCLING'; + showOrUpdateCyclingBar(); + saveSession(); + return; + } + + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) { + console.warn('[impeccable] Could not find original element in live DOM.'); + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants + ? visibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + saveSession(); + queueCheckpoint('svelte_component_anchor_missing'); + waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return; + } + + const wrapper = document.createElement('div'); + wrapper.dataset.impeccableVariants = sessionId; + wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1); + wrapper.dataset.impeccablePreview = 'svelte-component'; + wrapper.style.display = 'contents'; + + const mountTarget = document.createElement('div'); + mountTarget.dataset.impeccableComponentMount = sessionId; + mountTarget.style.display = 'contents'; + wrapper.appendChild(mountTarget); + + const insertMode = isSvelteInsertManifest(manifest); + const detachedOriginal = insertMode ? null : liveEl; + if (insertMode) { + removeInsertPlaceholderDom(); + if (manifest.position === 'before') liveEl.parentElement.insertBefore(wrapper, liveEl); + else liveEl.parentElement.insertBefore(wrapper, liveEl.nextSibling); + } else { + liveEl.parentElement.replaceChild(wrapper, liveEl); + } + + svelteComponentSession = { + sessionId, + manifest, + insertMode, + wrapperEl: wrapper, + mountTargetEl: mountTarget, + detachedOriginal, + mountedInstance: null, + mountedVariant: 0, + runtime: null, + propValues: buildSveltePropValuesFromLiveElement(detachedOriginal, manifest), + paramsByVariant, + }; + if (pendingSvelteComponentRetryObserver) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + } + recoveryWaitingForAnchor = false; + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + + const mounted = await mountSvelteComponentVariant(visibleVariant); + if (!mounted) { + // The compiled component threw (e.g. a Svelte compile error in the + // variant file). Don't strand the bar in an empty CYCLING state; restore + // the original element and reset to PICKING so the user can retry. + abortSvelteComponentInjection(sessionId, 'A variant failed to compile. Fix the component and re-run.'); + return; + } + + selectedElement = mountTarget.firstElementChild || mountTarget; + state = 'CYCLING'; + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.'); + } catch (err) { + console.error('[impeccable] Failed to mount Svelte component variants:', err); + abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.'); + } + } + + function waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }) { + if (pendingSvelteComponentRetryObserver) pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = new MutationObserver(() => { + if (svelteComponentSession?.sessionId === sessionId) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + return; + } + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) return; + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + injectSvelteComponentsFromManifest(manifestPath, sessionId); + }); + pendingSvelteComponentRetryObserver.observe(document.body, { childList: true, subtree: true }); + } + + // Reset cleanly when a Svelte component session can't mount: tear the wrapper + // down (restoring the original element), clear persisted session state, and + // return the bar to PICKING. Avoids the stuck 0/0 CYCLING bar. + function abortSvelteComponentInjection(sessionId, message) { + try { + if (svelteComponentSession?.sessionId === sessionId) { + teardownSvelteComponentSession(true); + } else { + const orphan = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (orphan) orphan.remove(); + } + } catch (err) { + console.warn('[impeccable] Svelte component abort cleanup failed:', err); + } + hideShaderOverlay(); + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + stopScrollLock(); + clearSession(); + clearHandled(); + resetSessionFileMeta(); + currentSessionId = null; + expectedVariants = 0; + arrivedVariants = 0; + visibleVariant = 0; + selectedElement = null; + state = 'PICKING'; + hideBar(); + if (message) showToast(message, 5000); } /** @@ -4101,6 +4881,11 @@ * This works even when the dev server caches HTML (Bun, static servers). */ function injectVariantsFromSource(filePath, sessionId) { + if (isSvelteComponentManifestPath(filePath)) { + injectSvelteComponentsFromManifest(filePath, sessionId); + return; + } + rememberSessionFileMeta({ file: filePath }); const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) @@ -4119,7 +4904,7 @@ const doc = parser.parseFromString(block, 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { - console.error('[impeccable] Variant wrapper not found in source file.'); + console.warn('[impeccable] Variant wrapper not found in source file.'); return; } @@ -4134,31 +4919,31 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; - if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - } - + const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); if (!liveEl) { - console.error('[impeccable] Could not find original element in live DOM.'); + console.warn('[impeccable] Could not find original element in live DOM.'); + selectedElement = document.body; + recoveryWaitingForAnchor = true; + state = 'GENERATING'; + showBar('generating'); + saveSession(); + showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } + recoveryWaitingForAnchor = false; // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); arrivedVariants = variants.length; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + recoverEmptyCycling('source-fallback-empty'); + return; + } const saved = loadSession(); const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants @@ -4170,8 +4955,9 @@ selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4184,21 +4970,129 @@ }); } - function cycleVariant(dir) { + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { + const map = new Map(); + if (!sourceOriginal || !liveOriginal) return map; + + const sourceNodes = collectTextNodes(sourceOriginal) + .filter((node) => /\{[^{}]+\}/.test(node.nodeValue || '')); + const liveTexts = collectTextNodes(liveOriginal) + .map((node) => normalizePreviewText(node.nodeValue || '')) + .filter(Boolean); + let liveIndex = 0; + + for (const sourceNode of sourceNodes) { + const sourceText = sourceNode.nodeValue || ''; + const tokens = sourceText.match(/\{[^{}]+\}/g) || []; + if (tokens.length === 0) continue; + + const liveText = liveTexts[liveIndex++] || ''; + if (!liveText) continue; + + if (tokens.length === 1) { + const token = tokens[0]; + const normalizedSource = normalizePreviewText(sourceText); + if (normalizedSource === token) { + map.set(token, liveText); + continue; + } + + const match = liveText.match(expressionTextMatcher(sourceText, [token])); + if (match && match[1]) map.set(token, match[1].trim()); + continue; + } + + if (normalizePreviewText(sourceText) === tokens.join(' ')) { + for (const token of tokens) { + const tokenLiveText = liveTexts[liveIndex - 1] || ''; + if (tokenLiveText) map.set(token, tokenLiveText); + } + } + } + + return map; + } + + function expressionTextMatcher(sourceText, tokens) { + let pattern = '^'; + let cursor = 0; + for (const token of tokens) { + const index = sourceText.indexOf(token, cursor); + if (index === -1) continue; + pattern += escapeRegExp(sourceText.slice(cursor, index)).replace(/\s+/g, '\\s*'); + pattern += '(.*?)'; + cursor = index + token.length; + } + pattern += escapeRegExp(sourceText.slice(cursor)).replace(/\s+/g, '\\s*') + '$'; + return new RegExp(pattern); + } + + function collectTextNodes(root) { + if (!root) return []; + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + nodes.push(node); + node = walker.nextNode(); + } + return nodes; + } + + function normalizePreviewText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + async function selectVariant(next, checkpointReason) { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - const next = visibleVariant + dir; + if (variantSelectionInFlight) return; if (next < 1 || next > arrivedVariants) return; - visibleVariant = next; - showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself - updateSelectedElement(); - updateBarContent('cycling'); - positionBar(); - saveSession(); - queueCheckpoint('variant_changed'); + if (next === visibleVariant) return; + + const previous = visibleVariant; + variantSelectionInFlight = true; + const selectionPromise = (async () => { + visibleVariant = next; + showOrUpdateCyclingBar(); + saveSession(); + const shown = await showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself + if (!shown) { + visibleVariant = previous; + await showVariantInDOM(currentSessionId, previous); + showOrUpdateCyclingBar(); + saveSession(); + return; + } + updateSelectedElement(); + showOrUpdateCyclingBar(); + positionBar(); + saveSession(); + if (checkpointReason) queueCheckpoint(checkpointReason); + })(); + variantSelectionPromise = selectionPromise; + try { + await selectionPromise; + } finally { + if (variantSelectionPromise === selectionPromise) variantSelectionPromise = null; + variantSelectionInFlight = false; + } + } + + function cycleVariant(dir) { + selectVariant(visibleVariant + dir, 'variant_changed'); } function updateSelectedElement() { if (!currentSessionId) return; + if (svelteComponentSession?.sessionId === currentSessionId) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; + return; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); @@ -4206,6 +5100,9 @@ } function readVisibleVariantFromDOM(sessionId) { + if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { + return svelteComponentSession.mountedVariant; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -4341,9 +5238,9 @@ // scrollY that the next resume needs to read. } - // --------------------------------------------------------------------------- + // // MutationObserver for progressive variant reveal - // --------------------------------------------------------------------------- + // function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -4426,10 +5323,11 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); updateSelectedElement(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4445,9 +5343,9 @@ return obs; } - // --------------------------------------------------------------------------- + // // Bar scroll tracking - // --------------------------------------------------------------------------- + // function startScrollTracking() { function tick() { @@ -4483,10 +5381,10 @@ if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; } } - // --------------------------------------------------------------------------- + // // SSE (server→browser) + fetch POST (browser→server) // Zero-dependency replacement for WebSocket. - // --------------------------------------------------------------------------- + // let evtSource = null; let sseRetries = 0; @@ -4509,6 +5407,7 @@ console.log('[impeccable] Live mode connected.'); syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); + restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; syncPageChatFocus('sse-connected'); break; @@ -4531,11 +5430,12 @@ break; case 'done': if (maybeCompleteSteer(msg)) break; + rememberSessionFileMeta(msg); // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); } @@ -4557,7 +5457,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; if (state !== 'GENERATING') return; showToast( - "Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.", + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", 15000, ); }, 2000); @@ -4571,12 +5471,18 @@ // the final complete event. Keep the browser in its recoverable // saving state while the source cleanup is still in flight. break; + case 'discarded': + if (msg.id && msg.id === currentSessionId) { + markSessionHandled(); + cleanup(); + } + break; case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; state = 'CYCLING'; updateBarContent('cycling'); - showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000); + showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; } if (maybeCompleteSteer(msg)) break; @@ -4656,6 +5562,9 @@ expectedVariants, arrivedVariants, visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, paramValues: { ...paramsCurrentValues }, }; } @@ -4665,6 +5574,20 @@ return sendEvent(checkpointPayload(reason)).catch(() => null); } + function sendSteerCheckpoint(id, reason, extra) { + if (!id) return Promise.resolve(null); + return sendEvent({ + type: 'checkpoint', + id, + revision: sessionState.nextCheckpointRevision(), + owner: browserOwner, + phase: 'steer', + reason, + pageUrl: location.pathname, + ...(extra || {}), + }).catch(() => null); + } + function queueCheckpoint(reason) { if (!currentSessionId) return; if (checkpointTimer) clearTimeout(checkpointTimer); @@ -4674,9 +5597,9 @@ }, 120); } - // --------------------------------------------------------------------------- + // // Event handlers - // --------------------------------------------------------------------------- + // function handleMouseMove(e) { if (pendingApplyInFlight) return; @@ -4860,7 +5783,7 @@ // // DISABLED: quick-Go workflows pay an extra harness round trip because // prefetch + generate arrive as two events instead of one. Re-enable with - // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to + // a browser-side debounce (~800-1000ms, cancelled on Go) if we want to // resurrect this. Server validator and skill dispatch remain in place so // flipping this flag is the only change needed. const PREFETCH_ENABLED = false; @@ -4876,6 +5799,14 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + const deepActive = activeElementDeep(); + if ( + deepActive + && own(deepActive) + && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + ) { + return; + } // While a contenteditable text-leaf is focused, let the browser handle // all keys except Escape. Escape cancels the current edit (restores // original text) and blurs without saving, staying in CONFIGURING. @@ -4982,7 +5913,7 @@ if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!selectedElement || state !== 'CONFIGURING') return; stopVoice({ suppressSubmit: true }); - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; // Commit any pending pin edit BEFORE we snapshot annotations. @@ -4996,6 +5927,7 @@ expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); // Flip to GENERATING immediately so the bar morphs without waiting on // capture + upload. The event is emitted from captureAndEmit() once the @@ -5054,7 +5986,7 @@ function handleInsertCreate() { if (!placeholderElement || !insertAnchorElement || state !== 'CONFIGURING' || configureKind !== 'insert') return; - const input = document.getElementById(PREFIX + '-insert-input'); + const input = uiGetById(PREFIX + '-insert-input'); const prompt = input ? input.value.trim() : ''; if (annotEditing) finalizeEditingPin(); const snapshot = { @@ -5064,10 +5996,12 @@ if (!canCreateInsert({ prompt, comments: snapshot.comments, strokes: snapshot.strokes })) return; stopVoice({ suppressSubmit: true }); + pendingAcceptedSession = null; currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); selectedElement = placeholderElement; insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement); @@ -5107,9 +6041,9 @@ captureAndEmit(elForCapture, basePayload, snapshot, captureRect); } - // --------------------------------------------------------------------------- + // // Screenshot capture + upload - // --------------------------------------------------------------------------- + // let msLoadPromise = null; function loadModernScreenshot() { @@ -5120,7 +6054,7 @@ s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; s.onload = () => resolve(window.modernScreenshot); s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; - document.head.appendChild(s); + uiAppendStyle(s); }); return msLoadPromise; } @@ -5235,11 +6169,113 @@ return '#ffffff'; } + function captureChromeNodes() { + const nodes = []; + const add = (node) => { + if (!node || node === document.body || nodes.includes(node)) return; + nodes.push(node); + }; + add(document.getElementById(PREFIX + '-root')); + [ + PREFIX + '-highlight', + PREFIX + '-tooltip', + PREFIX + '-bar', + PREFIX + '-picker', + PREFIX + '-params-panel', + PREFIX + '-insert-line', + PREFIX + '-insert-placeholder', + PREFIX + '-insert-create-tooltip', + PREFIX + '-annot', + PREFIX + '-design-host', + PREFIX + '-toast', + PREFIX + '-shader', + ].forEach((id) => add(uiGetById(id))); + return nodes; + } + + async function hideCaptureChromeForShaderProxy(fn) { + const saved = captureChromeNodes().map((node) => ({ + node, + visibility: node.style.visibility, + priority: node.style.getPropertyPriority('visibility'), + })); + for (const { node } of saved) { + node.style.setProperty('visibility', 'hidden', 'important'); + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + try { + return await fn(); + } finally { + for (const { node, visibility, priority } of saved) { + node.style.setProperty('visibility', visibility, priority); + } + } + } + + function shouldUseAncestorCropShaderProxy(el) { + // TODO: Enable this proxy for React/Vue/etc. adapters once their live + // preview mounts are covered by the same shader regression checks. + const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase(); + if (adapter === 'svelte' || adapter === 'sveltekit') return true; + if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true; + const wrapper = el?.closest?.('[data-impeccable-variants]'); + return wrapper?.dataset?.impeccablePreview === 'svelte-component'; + } + + function paintsShaderProxySurface(node) { + const s = getComputedStyle(node); + return !isTransparentColor(s.backgroundColor) + || (s.backgroundImage && s.backgroundImage !== 'none') + || paintsBackdrop(node); + } + + function findShaderProxyCaptureRoot(el) { + const doc = el.ownerDocument || document; + const er = el.getBoundingClientRect(); + let node = el.parentElement; + while (node && node !== doc.documentElement) { + const nr = node.getBoundingClientRect(); + const containsElement = + nr.width > 0 && nr.height > 0 && + nr.left <= er.left + 0.5 && + nr.top <= er.top + 0.5 && + nr.right >= er.right - 0.5 && + nr.bottom >= er.bottom - 0.5; + if (containsElement && paintsShaderProxySurface(node)) return node; + node = node.parentElement; + } + return null; + } + // Capture the element (with current annotations baked in) and return // { blob, paper }: the PNG Blob, plus the representative backdrop tone for the // shader's halftone ground (so capture, upload, and shader all agree on what // sits behind the element). Shared between the Go flow (uploads the blob) and // the shader-resume path. + async function captureElementFromRenderedAncestor(ms, el, opts) { + const doc = el.ownerDocument || document; + const captureRoot = findShaderProxyCaptureRoot(el); + if (!captureRoot) throw new Error('No painted ancestor for Svelte shader proxy'); + const rootCanvas = await ms.domToCanvas(captureRoot, opts); + const S = opts.scale; + const er = el.getBoundingClientRect(); + const rr = captureRoot.getBoundingClientRect(); + const sx = (er.left - rr.left) * S; + const sy = (er.top - rr.top) * S; + const sw = er.width * S; + const sh = er.height * S; + if (sw <= 0 || sh <= 0) throw new Error('Selected element has no visible capture rect'); + const crop = doc.createElement('canvas'); + crop.width = Math.max(1, Math.round(sw)); + crop.height = Math.max(1, Math.round(sh)); + const cctx = crop.getContext('2d', { willReadFrequently: true }); + cctx.drawImage(rootCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height); + const paper = dominantRgb01(cctx, crop.width, crop.height) || averageRgb01(cctx, crop.width, crop.height); + const blob = await new Promise((res) => crop.toBlob(res, 'image/png')); + if (!blob) throw new Error('Ancestor crop failed to produce a PNG blob'); + return { blob, paper }; + } + async function captureElementToBlob(el, snapshot, rect) { try { if (document.fonts?.ready) await document.fonts.ready; } catch {} const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); @@ -5261,6 +6297,13 @@ scale: Math.min(window.devicePixelRatio || 1, 2), font: fontCssText ? { cssText: fontCssText } : undefined, }; + if (shouldUseAncestorCropShaderProxy(el)) { + try { + return await hideCaptureChromeForShaderProxy(() => captureElementFromRenderedAncestor(ms, el, opts)); + } catch (err) { + console.warn('[impeccable] Svelte ancestor crop capture failed, falling back to element capture:', err); + } + } const bg = resolveCanvasBackground(el); // Fast path: the element paints its own background, or an opaque ancestor // color was found. modern-screenshot bakes that color; paper matches it. @@ -5342,13 +6385,13 @@ sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); } - // --------------------------------------------------------------------------- + // // Shader overlay - renders the captured screenshot as a WebGL texture and // runs an editorial "ink-wash" fragment shader over it during generation. // A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku // and leaving a soft trail. Makes the wait feel like a letterpress scan // instead of a dead spinner. - // --------------------------------------------------------------------------- + // const SHADER_VS = `attribute vec2 a_position; attribute vec2 a_uv; @@ -5502,6 +6545,31 @@ void main() { return n ? [r / n / 255, g / n / 255, b / n / 255] : SHADER_PAPER_FALLBACK; } + // Pick the most common visible color cluster from a crop. A straight average + // gets pulled by text and icons; the dominant bucket usually represents the + // surface the shader should dissolve into. + function dominantRgb01(ctx, w, h) { + const data = ctx.getImageData(0, 0, w, h).data; + const stride = Math.max(1, Math.floor((w * h) / 6000)); + const buckets = new Map(); + for (let p = 0; p < w * h; p += stride) { + const i = p * 4; + if (data[i + 3] < 16) continue; + const key = (data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4); + const bucket = buckets.get(key) || { count: 0, r: 0, g: 0, b: 0 }; + bucket.count += 1; + bucket.r += data[i]; + bucket.g += data[i + 1]; + bucket.b += data[i + 2]; + buckets.set(key, bucket); + } + let best = null; + for (const bucket of buckets.values()) { + if (!best || bucket.count > best.count) best = bucket; + } + return best ? [best.r / best.count / 255, best.g / best.count / 255, best.b / best.count / 255] : null; + } + // Average the backdrop sampled just OUTSIDE an element's rect within a larger // canvas. The ground tone for the dissolve must be the real backdrop, not the // mean of the element's own crop - averaging the crop folds in the element's @@ -5572,7 +6640,7 @@ void main() { fallback.style.backgroundRepeat = 'no-repeat'; fallback.style.outline = '2px dashed ' + C.brand; fallback.style.outlineOffset = '-2px'; - document.body.appendChild(fallback); + uiAppend(fallback); shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl }; } @@ -5582,16 +6650,19 @@ void main() { const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); + const radius = getComputedStyle(el).borderRadius; canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); Object.assign(canvas.style, { position: 'fixed', top: rect.top + 'px', left: rect.left + 'px', width: rect.width + 'px', height: rect.height + 'px', + borderRadius: radius, + overflow: 'hidden', pointerEvents: 'none', zIndex: Z.bar - 1, }); - document.body.appendChild(canvas); + uiAppend(canvas); const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) || canvas.getContext('experimental-webgl'); @@ -5685,8 +6756,12 @@ void main() { frame(); } - function handleAccept() { + async function handleAccept() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } + if (pendingAcceptedSession || state === 'SAVING') return; + if (variantSelectionPromise) { + try { await variantSelectionPromise; } catch { /* failed selection falls back below */ } + } if (!currentSessionId || arrivedVariants === 0) return; const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId); if (domVisibleVariant > 0) visibleVariant = domVisibleVariant; @@ -5696,30 +6771,39 @@ void main() { variantId: String(visibleVariant), pageUrl: location.pathname, }; + const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } + // The accepted variant is already the only visible child of the wrapper + // (all other variants are display:none). HMR from the source rewrite will + // replace the wrapper imminently. Don't eagerly replaceChild here - React + // reconciliation races with our mutation and throws NotFoundError in Next + // 16 / Turbopack. Schedule a fallback that runs the manual swap only if + // HMR hasn't cleaned up by then (keeps static-server flows working). const acceptedSessionId = currentSessionId; const acceptedVariant = visibleVariant; + const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId + || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - pendingAcceptedSession = { - id: acceptedSessionId, - variant: String(acceptedVariant), - ...acceptedSnapshot, - finalizing: false, - }; state = 'SAVING'; updateBarContent('saving'); + pendingAcceptedSession = { + id: acceptedSessionId, + variant: String(acceptedVariant), + isSvelteComponent: acceptedIsSvelteComponent, + ...acceptedSnapshot, + finalizing: false, + }; + saveSession(); sendEvent(acceptPayload, { throwOnError: true }) - .then(() => { - markSessionHandled(); - }) + .then(() => {}) .catch(() => { - pendingAcceptedSession = null; + if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); } @@ -5733,19 +6817,21 @@ void main() { } if (pending.finalizing) return true; pending.finalizing = true; - + markSessionHandled(); + if (pending.isSvelteComponent) { + commitAcceptedSvelteComponentToDom(pending.id); + } state = 'CONFIRMED'; updateBarContent('confirmed'); + scheduleAcceptCleanup(pending); + return true; + } - // Give framework HMR a short chance to render the now-clean accepted - // source. If it misses the update, unwrap the accepted variant after the - // source-side completion event so the page is not left empty or stale. + function scheduleAcceptCleanup(accepted) { setTimeout(function() { - ensureAcceptedDomClean(pending); + if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted); cleanupAcceptedSession(); }, 1200); - - return true; } function snapshotAcceptedVariantDom(sessionId, variantId) { @@ -5833,6 +6919,7 @@ void main() { stopScrollLock(); clearScrollY(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5841,6 +6928,28 @@ void main() { state = 'PICKING'; } + function commitAcceptedVariantToDom(sessionId, variantId) { + const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapper) return false; + const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); + if (!accepted || !accepted.firstElementChild) return false; + const parent = wrapper.parentElement; + if (!parent) return false; + + const style = wrapper.querySelector('style[data-impeccable-css]'); + if (style && !document.querySelector('style[data-impeccable-accepted-css="' + sessionId + '"]')) { + const promotedStyle = style.cloneNode(true); + promotedStyle.setAttribute('data-impeccable-accepted-css', sessionId); + parent.insertBefore(promotedStyle, wrapper); + } + + const committed = accepted.cloneNode(true); + committed.removeAttribute('hidden'); + committed.style.display = 'contents'; + parent.replaceChild(committed, wrapper); + return true; + } + function handleDiscard() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!currentSessionId) return; @@ -5852,11 +6961,141 @@ void main() { .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000)); } - // --------------------------------------------------------------------------- + // // Session persistence via live-browser-session.js - // --------------------------------------------------------------------------- + // // Survives page reloads, browser close/reopen, HMR, and accidental refreshes. + function normalizeSessionPath(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed.replace(/\\/g, '/') : null; + } + + function resetSessionFileMeta() { + currentSourceFile = null; + currentPreviewFile = null; + currentPreviewMode = null; + recoveryWaitingForAnchor = false; + } + + function rememberSessionFileMeta(meta = {}) { + const file = normalizeSessionPath(meta.file); + const sourceFile = normalizeSessionPath(meta.sourceFile); + const previewFile = normalizeSessionPath(meta.previewFile); + const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null); + + if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) { + currentPreviewMode = 'svelte-component'; + currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile); + currentSourceFile = sourceFile || currentSourceFile; + return; + } + + if (sourceFile || file) currentSourceFile = sourceFile || file; + if (previewFile) currentPreviewFile = previewFile; + if (previewMode) currentPreviewMode = previewMode; + } + + function applySavedSessionMeta(saved) { + if (!saved) return; + rememberSessionFileMeta(saved); + if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.action) selectedAction = saved.action; + if (saved.count) selectedCount = saved.count; + if (saved.previewMode) currentPreviewMode = saved.previewMode; + if (saved.paramValues && typeof saved.paramValues === 'object') { + paramsCurrentValues = { ...saved.paramValues }; + } + } + + function normalizePagePath(value) { + if (!value || typeof value !== 'string') return null; + try { + return new URL(value, location.origin).pathname; + } catch { + return value.split(/[?#]/)[0] || null; + } + } + + function pageMatchesCurrent(value) { + const path = normalizePagePath(value); + return !path || path === location.pathname; + } + + function isTerminalSessionSummary(session) { + return /^(completed|discarded|discard_requested|accept_requested)$/.test(String(session?.phase || '')); + } + + function findActiveSessionSummary(saved, activeSessions) { + if (!saved?.id || !Array.isArray(activeSessions)) return null; + return activeSessions.find((session) => + session?.id === saved.id + && pageMatchesCurrent(session.pageUrl || saved.pageUrl) + && !isTerminalSessionSummary(session) + ) || null; + } + + function clampVariantIndex(value, count) { + const num = Number(value); + const max = Number(count); + if (!Number.isFinite(num) || num < 1) return 0; + if (Number.isFinite(max) && max > 0 && num > max) return 0; + return Math.floor(num); + } + + function restoreSessionWithoutWrapper(reason, activeSessions) { + const saved = loadSession(); + if (!saved?.id || isSessionHandled(saved.id)) return false; + const savedState = String(saved.state || '').toUpperCase(); + if (savedState !== 'GENERATING' && savedState !== 'CYCLING') return false; + + const serverSession = findActiveSessionSummary(saved, activeSessions); + if (Array.isArray(activeSessions) && activeSessions.length > 0 && !serverSession) { + return false; + } + + currentSessionId = saved.id; + applySavedSessionMeta(serverSession); + applySavedSessionMeta(saved); + + expectedVariants = Number(saved.expected || serverSession?.expectedVariants || selectedCount || 0); + arrivedVariants = Number(saved.arrived || serverSession?.arrivedVariants || 0); + if (arrivedVariants <= 0 && currentPreviewFile) arrivedVariants = Number(serverSession?.expectedVariants || saved.expected || selectedCount || 0); + if (expectedVariants <= 0) expectedVariants = Number(serverSession?.expectedVariants || arrivedVariants || selectedCount || 0); + visibleVariant = clampVariantIndex(saved.visible, arrivedVariants || expectedVariants) + || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) + || (arrivedVariants > 0 ? 1 : 0); + + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + if (variantObserver) variantObserver.disconnect(); + variantObserver = startVariantObserver(currentSessionId); + saveSession(); + queueCheckpoint(reason || 'browser_restore_without_wrapper'); + + const restoreFile = currentPreviewMode === 'svelte-component' + ? currentPreviewFile + : (currentSourceFile || currentPreviewFile); + if (restoreFile) { + injectVariantsFromSource(restoreFile, currentSessionId); + return true; + } + + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return true; + } + + function restoreFromActiveSessions(activeSessions, reason) { + const wrapper = document.querySelector('[data-impeccable-variants]'); + if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false; + if (svelteComponentSession?.sessionId === currentSessionId) return false; + return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); + } + function saveSession() { if (!currentSessionId) return; // NOTE: scrollY is stored under a separate key (writeScrollY). Storing @@ -5869,6 +7108,11 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, + pageUrl: location.pathname, + paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, }); } @@ -5898,31 +7142,33 @@ void main() { } function cleanup() { - // Hide the wrapper immediately so variants disappear. DON'T structurally - // mutate the DOM yet - HMR from the agent's source rewrite is on its way, - // and a manual replaceChild under React causes NotFoundError when the - // reconciler later tries to remove a wrapper we already removed. - // Schedule a 2s fallback that does the manual swap only if HMR hasn't - // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const cleanupSessionId = currentSessionId; - if (cleanupSessionId) { + if (svelteComponentSession?.sessionId === cleanupSessionId) { + teardownSvelteComponentSession(true); + } else if (cleanupSessionId) { + // Hide the wrapper immediately so variants disappear. DON'T structurally + // mutate the DOM yet - HMR from the agent's source rewrite is on its way, + // and a manual replaceChild under React causes NotFoundError when the + // reconciler later tries to remove a wrapper we already removed. + // Schedule a 2s fallback that does the manual swap only if HMR hasn't + // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); if (wrapper) wrapper.style.display = 'none'; - } - setTimeout(function() { - if (!cleanupSessionId) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!wrapper) return; - const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - wrapper.parentElement.replaceChild(content, wrapper); - return; + setTimeout(function() { + if (!cleanupSessionId) return; + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) return; + const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); + if (orig) { + const content = orig.firstElementChild; + if (content) { + lateWrapper.parentElement.replaceChild(content, lateWrapper); + return; + } } - } - wrapper.remove(); - }, 2000); + lateWrapper.remove(); + }, 2000); + } hideBar(); hideHighlight(); stopScrollTracking(); @@ -5931,6 +7177,7 @@ void main() { clearScrollY(); finalizeInsertSession(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5938,9 +7185,9 @@ void main() { state = 'PICKING'; } - // --------------------------------------------------------------------------- + // // Toast - // --------------------------------------------------------------------------- + // function showToast(message, duration) { if (toastEl) toastEl.remove(); @@ -5964,7 +7211,7 @@ void main() { }); toastEl.id = PREFIX + '-toast'; toastEl.textContent = message; - document.body.appendChild(toastEl); + uiAppend(toastEl); requestAnimationFrame(() => { toastEl.style.opacity = '1'; toastEl.style.transform = 'translateX(-50%) translateY(0)'; @@ -5978,22 +7225,70 @@ void main() { }, duration); } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. function resumeSession() { const wrapper = document.querySelector('[data-impeccable-variants]'); - if (!wrapper) { clearSession(); clearHandled(); return false; } + if (!wrapper) { + if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } const sessionId = wrapper.dataset.impeccableVariants; // Don't resume if this session was already accepted/discarded if (isSessionHandled(sessionId)) return false; + // Svelte component sessions can't be resumed by counting DOM children: the + // wrapper holds a single mount target, not [data-impeccable-variant] nodes, + // and a page reload unmounts every compiled variant. Counting children here + // would strand the bar in CYCLING at 0/0. If there's no live in-memory mount + // for this wrapper, it's an orphan (reload / failed mount): drop it and let + // the live-server's SSE re-inject the manifest if the session is still live. + if (wrapper.dataset.impeccablePreview === 'svelte-component' + && svelteComponentSession?.sessionId !== sessionId) { + wrapper.remove(); + if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } + + if (wrapper.dataset.impeccablePreview === 'svelte-component') { + if (!svelteComponentSession?.mountedVariant) { + return true; + } + currentSessionId = sessionId; + expectedVariants = Number(wrapper.dataset.impeccableVariantCount) + || Number(svelteComponentSession.manifest?.count) + || expectedVariants + || 1; + arrivedVariants = expectedVariants; + const saved = loadSession(); + applySavedSessionMeta(saved); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = svelteComponentSession.mountedVariant > 0 && svelteComponentSession.mountedVariant <= arrivedVariants + ? svelteComponentSession.mountedVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = resolveSvelteComponentAnchor() + || wrapper.parentElement; + state = 'CYCLING'; + hideShaderOverlay(); + showBar('cycling'); + startScrollTracking(); + refreshParamsPanel(); + saveSession(); + queueCheckpoint('browser_resumed_svelte_component'); + return true; + } + currentSessionId = sessionId; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -6002,6 +7297,7 @@ void main() { // Restore state from localStorage if available const saved = loadSession(); if (saved && saved.id === sessionId) { + applySavedSessionMeta(saved); visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0); if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; @@ -6072,9 +7368,9 @@ void main() { return true; } - // --------------------------------------------------------------------------- + // // Global bar (always visible at bottom) - // --------------------------------------------------------------------------- + // let globalBarEl = null; let globalBarBrandEl = null; @@ -6166,6 +7462,8 @@ void main() { let pageChatExpanded = false; let steerLocked = false; let steerRequestId = null; + let steerPendingMessage = ''; + let steerInputWasFocused = false; let pageChatDotsEl = null; let steerAwaitTimer = null; let voiceRecognition = null; @@ -6179,7 +7477,7 @@ void main() { const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; - const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect'; + const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect'; const GLOBAL_BAR_SECTION_GAP = 8; const GLOBAL_BAR_INNER_GAP = 2; const GLOBAL_BAR_INNER_PAD_LEFT = 2; @@ -6323,7 +7621,7 @@ void main() { const attempt = () => { steerFocusRecoverTimer = null; if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (pageHasHostTextSelection()) { steerFocusRecoverTimer = setTimeout(attempt, 120); return; @@ -6344,7 +7642,7 @@ void main() { steerFocusSuspended = true; steerFocusPauseUntil = performance.now() + STEER_PAGE_FOCUS_PAUSE_MS; pagePointerGesture = { x: e.clientX, y: e.clientY, dragged: false }; - if (pageChatInput && document.activeElement === pageChatInput) { + if (pageChatInput && activeElementDeep() === pageChatInput) { pageChatInput.blur(); } } @@ -6404,7 +7702,7 @@ void main() { pickActive, pageChatReady: !!pageChatInput, pageChatExpanded, - active: steerFocusTargetLabel(document.activeElement), + active: steerFocusTargetLabel(activeElementDeep()), shouldSteer: shouldFocusSteerChat(), ...(extra || {}), }); @@ -6423,26 +7721,26 @@ void main() { function focusConfigureInput(reason) { steerFocusLog('focusConfigureInput', { reason }); const inputId = configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input'; - const input = document.getElementById(inputId); + const input = uiGetById(inputId); if (!input) { steerFocusLog('focusConfigureInput missing', { reason }); return; } setTimeout(() => { - const before = document.activeElement; + const before = activeElementDeep(); input.focus(); steerFocusLog('focusConfigureInput result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== input, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== input, }); }, 60); } function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; - const focused = document.activeElement === pageChatInput; + const focused = activeElementDeep() === pageChatInput; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; const P = pageChatPalette(); pageChatEl.style.borderColor = steerLocked @@ -6476,15 +7774,15 @@ void main() { } syncPageChatVisual(); pageChatInput.style.pointerEvents = 'auto'; - const before = document.activeElement; + const before = activeElementDeep(); try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== pageChatInput, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== pageChatInput, }); } @@ -6515,6 +7813,37 @@ void main() { return wrap; } + function keepSteerPointerInside(e, opts = {}) { + e.stopPropagation(); + if (opts.preventDefault !== false) e.preventDefault(); + } + + function preparePageChatInputForTyping() { + if (!pageChatEl || !pageChatInput) return false; + pageChatExpanded = true; + pageChatEl.dataset.expanded = 'true'; + pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; + pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return true; + } + + function focusPageChatInput(reason) { + if (!preparePageChatInputForTyping() || steerLocked) return false; + try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } + const focused = activeElementDeep() === pageChatInput; + if (focused) steerInputWasFocused = true; + syncPageChatFocusRing(); + return focused; + } + function clearSteerAwaitTimer() { if (steerAwaitTimer) { clearTimeout(steerAwaitTimer); @@ -6528,6 +7857,7 @@ void main() { if (!steerLocked || steerRequestId !== id) return; unlockSteerChat({ error: 'Steer timed out waiting for the agent. Check that live-poll is running and replies with steer_done.', + restoreMessage: steerPendingMessage, }); }, STEER_AWAIT_TIMEOUT_MS); } @@ -6538,19 +7868,12 @@ void main() { steerLocked = true; pageChatEl.dataset.processing = 'true'; pageChatInput.disabled = true; - pageChatInput.value = ''; - pageChatInput.blur(); + preparePageChatInputForTyping(); if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = true; pageChatVoiceBtn.style.display = 'none'; } - pageChatExpanded = false; - pageChatEl.dataset.expanded = 'false'; - pageChatEl.style.width = PAGE_CHAT_PROCESSING_W; pageChatEl.style.cursor = 'default'; - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; pageChatInput.style.pointerEvents = 'none'; if (pageChatHint) { pageChatHint.style.display = 'none'; @@ -6568,17 +7891,26 @@ void main() { function unlockSteerChat(opts) { clearSteerAwaitTimer(); + const restoreMessage = typeof opts?.restoreMessage === 'string' ? opts.restoreMessage : ''; + const keepExpanded = Boolean(opts?.error && restoreMessage); steerLocked = false; + const completedId = steerRequestId; steerRequestId = null; if (!pageChatEl) return; pageChatEl.dataset.processing = 'false'; pageChatEl.removeAttribute('aria-busy'); pageChatEl.setAttribute('aria-label', 'Steer the page'); - pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W; + pageChatExpanded = keepExpanded; + pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false'; + pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W; pageChatEl.style.cursor = 'pointer'; if (pageChatInput) { pageChatInput.disabled = false; - pageChatInput.value = ''; + pageChatInput.value = keepExpanded ? restoreMessage : ''; + pageChatInput.style.width = keepExpanded ? '' : '0'; + pageChatInput.style.padding = keepExpanded ? '0 6px' : '0'; + pageChatInput.style.opacity = keepExpanded ? '1' : '0'; + pageChatInput.style.pointerEvents = 'auto'; } if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = false; @@ -6586,18 +7918,28 @@ void main() { } if (pageChatHint) { pageChatHint.textContent = 'Steer'; - pageChatHint.style.display = ''; - pageChatHint.style.visibility = ''; + pageChatHint.style.display = keepExpanded ? 'none' : ''; + pageChatHint.style.visibility = keepExpanded ? 'hidden' : ''; + pageChatHint.style.opacity = keepExpanded ? '0' : '1'; } if (pageChatDotsEl?.parentNode) { pageChatDotsEl.remove(); pageChatDotsEl = null; } + steerPendingMessage = keepExpanded ? restoreMessage : ''; + steerInputWasFocused = false; syncPageChatChrome(); syncPageChatFocusRing(); if (opts?.error) showToast(String(opts.error), 5000); else if (opts?.message) showToast(String(opts.message), 4000); - syncPageChatFocus('steer-unlock'); + if (completedId) { + sendSteerCheckpoint(completedId, opts?.error ? 'steer_error' : 'steer_done', { + message: opts?.message || opts?.error || '', + file: opts?.file || '', + }); + } + if (keepExpanded) focusPageChatInput('steer-error-restore'); + else syncPageChatFocus('steer-unlock'); } function steerSpeechRecognitionCtor() { @@ -6651,7 +7993,7 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = document.getElementById(PREFIX + '-configure-voice'); + const voiceBtn = uiGetById(PREFIX + '-configure-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -6784,7 +8126,7 @@ void main() { } function configureVoiceContext() { - const input = document.getElementById( + const input = uiGetById( configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input', ); return { @@ -6819,26 +8161,37 @@ void main() { if (!text || steerLocked) return; const id = id8(); steerRequestId = id; + steerPendingMessage = text; + if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); lockSteerChat(); scheduleSteerAwaitTimeout(id); + sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); sendEvent({ type: 'steer', id, message: text, pageUrl: location.href, }).then((res) => { - if (!res) unlockSteerChat({ error: 'Could not reach live server' }); + if (!res) { + sendSteerCheckpoint(id, 'steer_send_failed', { message: text }); + unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text }); + } }); } function maybeCompleteSteer(msg) { if (!steerRequestId || msg.id !== steerRequestId) return false; if (msg.type === 'steer_done') { - unlockSteerChat({ message: msg.message }); + unlockSteerChat({ message: msg.message, file: msg.file }); + if (msg.file && /\.svelte(?:$|\?)/.test(String(msg.file))) { + setTimeout(() => { + if (!steerLocked) showToast('Steer applied. Reload if the page has not refreshed yet.', 5000); + }, 4500); + } return true; } if (msg.type === 'error') { - unlockSteerChat({ error: msg.message || 'Steer failed' }); + unlockSteerChat({ error: msg.message || 'Steer failed', restoreMessage: steerPendingMessage }); return true; } return false; @@ -6847,21 +8200,10 @@ void main() { function expandPageChat(opts) { const focus = !opts || opts.focus !== false; if (!pageChatEl || !pageChatInput || steerLocked) return; - pageChatExpanded = true; - pageChatEl.dataset.expanded = 'true'; - pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; - pageChatEl.style.cursor = 'text'; - if (pageChatHint) { - pageChatHint.style.display = 'none'; - pageChatHint.style.opacity = '0'; - } - pageChatInput.style.width = ''; - pageChatInput.style.padding = '0 6px'; - pageChatInput.style.opacity = '1'; - pageChatInput.style.pointerEvents = 'auto'; + preparePageChatInputForTyping(); syncPageChatChrome(); syncPageChatFocusRing(); - if (focus) pageChatInput.focus(); + if (focus) focusPageChatInput('expand-page-chat'); } function collapsePageChat(opts) { @@ -6878,7 +8220,7 @@ void main() { } else { pageChatInput.style.pointerEvents = 'auto'; } - if (pageChatHint && document.activeElement !== pageChatInput) { + if (pageChatHint && activeElementDeep() !== pageChatInput) { pageChatHint.style.display = ''; pageChatHint.style.opacity = '1'; } @@ -6952,7 +8294,7 @@ void main() { pageChatEl.appendChild(pageChatInput); pageChatEl.appendChild(pageChatVoiceBtn); - if (!document.getElementById(PREFIX + '-page-chat-style')) { + if (!uiGetById(PREFIX + '-page-chat-style')) { const s = document.createElement('style'); s.id = PREFIX + '-page-chat-style'; s.textContent = @@ -6966,23 +8308,34 @@ void main() { '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } - pageChatEl.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { + keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat(); + expandPageChat({ focus: false }); + focusPageChatInput('page-chat-click'); }); - pageChatVoiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); + pageChatVoiceBtn.addEventListener('mousedown', keepSteerPointerInside); pageChatVoiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); + keepSteerPointerInside(e); if (steerLocked) return; toggleSteerVoice(); }); + pageChatInput.addEventListener('pointerdown', keepSteerPointerInside); + pageChatInput.addEventListener('mousedown', keepSteerPointerInside); + pageChatInput.addEventListener('click', (e) => { + keepSteerPointerInside(e); + if (!steerLocked) focusPageChatInput('page-chat-input-click'); + }); + pageChatInput.addEventListener('input', () => { syncPageChatVisual(); }); @@ -6995,7 +8348,7 @@ void main() { syncPageChatFocusRing(); setTimeout(() => { if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (!pageChatInput.value.trim()) collapsePageChat(); scheduleSteerFocusRecover('steer-blur-recover'); }, 120); @@ -7039,7 +8392,7 @@ void main() { globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false'; globalBarBrandEl.setAttribute('aria-label', connected ? 'Impeccable live mode' - : 'Impeccable live mode: agent not polling'); + : 'Impeccable live mode - agent not polling'); globalBarBrandEl.removeAttribute('title'); globalBarBrandEl.style.cursor = connected ? 'default' : 'help'; const mark = globalBarBrandEl.querySelector('[data-brand-mark]'); @@ -7077,7 +8430,7 @@ void main() { }); agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip'; agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP; - document.body.appendChild(agentPollTooltipEl); + uiAppend(agentPollTooltipEl); return agentPollTooltipEl; } @@ -7131,7 +8484,7 @@ void main() { // Custom focus-visible for bar buttons. Browser default is a heavy // blue ring that looks jarring on the dark capsule. Replace with a // soft accent-tinted inner ring that respects the bar's palette. - if (!document.getElementById(PREFIX + '-bar-focus-style')) { + if (!uiGetById(PREFIX + '-bar-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-bar-focus-style'; s.textContent = @@ -7143,7 +8496,7 @@ void main() { '@keyframes impeccable-agent-dot { 0%, 100% { opacity: 0.45; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1); } }' + '#' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: impeccable-agent-dot 1.4s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: none; opacity: 0.9; } }'; - document.head.appendChild(s); + uiAppendStyle(s); } globalBarEl = el('div', { @@ -7176,7 +8529,7 @@ void main() { brand.id = PREFIX + '-global-bar-brand'; brand.dataset.agentConnected = 'false'; brand.setAttribute('role', 'img'); - brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling'); + brand.setAttribute('aria-label', 'Impeccable live mode - agent not polling'); const brandMark = el('span', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -7211,7 +8564,7 @@ void main() { inner.id = PREFIX + '-global-bar-inner'; globalBarEl.appendChild(inner); - // --- button factory: icon-only at rest, label slides in on hover/active --- + // Button factory: icon-only at rest, label slides in on hover/active. function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { const b = el('button', { position: 'relative', @@ -7506,6 +8859,7 @@ void main() { color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', }); + exitBtn.id = PREFIX + '-exit'; exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = 'oklch(58% 0.15 35)'; exitBtn.style.background = P.exitHover; }); @@ -7530,8 +8884,8 @@ void main() { try { window.focus(); } catch { /* in-app preview may block */ } }, true); - document.body.appendChild(pendingDockEl); - document.body.appendChild(globalBarEl); + uiAppend(pendingDockEl); + uiAppend(globalBarEl); defangOutsideHandlers(pendingDockEl); defangOutsideHandlers(globalBarEl); @@ -7553,11 +8907,11 @@ void main() { } function updateGlobalBarState() { - const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); - const detectBadge = document.getElementById(PREFIX + '-detect-badge'); - const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); - const insertToggle = document.getElementById(PREFIX + '-insert-toggle'); - const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const detectToggle = uiGetById(PREFIX + '-detect-toggle'); + const detectBadge = uiGetById(PREFIX + '-detect-badge'); + const pickToggle = uiGetById(PREFIX + '-pick-toggle'); + const insertToggle = uiGetById(PREFIX + '-insert-toggle'); + const designToggle = uiGetById(PREFIX + '-design-toggle'); const theme = globalBarEl?.dataset.theme || 'light'; const P = barPaletteForTheme(theme); @@ -7751,8 +9105,9 @@ void main() { pendingApplyInFlight = false; } if (globalBarEl) { - globalBarEl.style.transform = 'translateY(100%)'; - setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300); + globalBarEl.style.transition = 'none'; + globalBarEl.remove(); + globalBarEl = null; } pageChatEl = null; pageChatInput = null; @@ -7765,6 +9120,7 @@ void main() { if (barEl) { barEl.remove(); barEl = null; } if (pickerEl) { pickerEl.remove(); pickerEl = null; } if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; } + if (editBadgeProxyRoot) { editBadgeProxyRoot.remove(); editBadgeProxyRoot = null; editBadgeProxyByTarget = new Map(); } if (evtSource) { evtSource.close(); evtSource = null; } document.removeEventListener('mousemove', handleMouseMove, true); document.removeEventListener('click', handleClick, true); @@ -7777,9 +9133,9 @@ void main() { console.log('[impeccable] Live mode exited.'); } - // --------------------------------------------------------------------------- + // // Design System Panel - visualizes the project's .impeccable/design.json sidecar - // --------------------------------------------------------------------------- + // const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; const DESIGN_PANEL_WIDTH = 440; @@ -7847,7 +9203,7 @@ void main() { root.className = 'root'; designShadow.appendChild(root); - document.body.appendChild(designHost); + uiAppend(designHost); // The host is pointer-events: none; the panel inside the shadow DOM // manages its own auto/none. Events bubble through the shadow boundary, // so attaching here silences host-page outside-interaction handlers @@ -7889,7 +9245,7 @@ void main() { .root * { box-sizing: border-box; } button { font: inherit; color: inherit; } - /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + /* Panel shell: chrome matches the bar; body canvas stays neutral */ .panel { position: fixed; top: 12px; bottom: 72px; right: 12px; width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); @@ -7955,7 +9311,7 @@ void main() { .panel-body::-webkit-scrollbar { width: 8px; } .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } - /* --- States --- */ + /* States */ .empty, .loading, .error { margin: 16px 4px; padding: 28px 20px; text-align: center; @@ -7966,7 +9322,7 @@ void main() { .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; } .error { color: oklch(45% 0.15 25); } - /* --- Stale hint --- */ + /* Stale hint */ .stale { display: flex; align-items: center; gap: 8px; margin: 8px 4px 12px; @@ -7979,7 +9335,7 @@ void main() { .stale-text { flex: 1; min-width: 0; } .stale-text strong { color: ${DP.ink}; font-weight: 600; } - /* --- Parsed-md fallback banner --- */ + /* Parsed-md fallback banner */ .parsed-md-cta { margin: 8px 4px 14px; padding: 14px 16px; @@ -7991,7 +9347,7 @@ void main() { .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; } .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; } - /* --- Tile primitives --- */ + /* Tile primitives */ .tile { position: relative; background: ${DP.tile}; @@ -8010,7 +9366,7 @@ void main() { } .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; } - /* --- Color tile --- */ + /* Color tile */ .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; } .c-tile:hover { transform: translateY(-1px); } .c-hero { @@ -8025,7 +9381,7 @@ void main() { .c-ramp > span { flex: 1; } .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Type tile --- */ + /* Type tile */ .t-tile { } .t-specimen { margin: 4px 0 6px; @@ -8035,7 +9391,7 @@ void main() { .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; } .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Shadow tile --- */ + /* Shadow tile */ .s-tile { } .s-surface { height: 60px; margin: 8px 2px 10px; @@ -8045,14 +9401,14 @@ void main() { .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; } .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; } - /* --- Radii strip --- */ + /* Radii strip */ .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; } .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; } .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); } .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; } .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; } - /* --- Component tile (hosts live primitives) --- */ + /* Component tile (hosts live primitives) */ .cmp-tile { } .cmp-stage { margin: 12px -4px 0; @@ -8066,7 +9422,7 @@ void main() { .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; } .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; } - /* --- Collapsible --- */ + /* Collapsible */ .coll { margin: 0 4px 8px; background: ${DP.tile}; @@ -8131,7 +9487,7 @@ void main() { .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; } .coll .overview-body li { margin-bottom: 3px; } - /* --- raw tab markdown (unchanged layout, neutralized palette) --- */ + /* raw tab markdown (unchanged layout, neutralized palette) */ .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; } .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; } .md h1 { font-size: 18px; } @@ -8303,7 +9659,7 @@ void main() { return box; } - // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 --- + // Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 function renderDesignVisual(body, parsed, sidecar) { const frontmatter = parsed?.frontmatter || {}; @@ -8667,7 +10023,7 @@ void main() { return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components'); } - // --- Collapsibles --------------------------------------------------------- + // Collapsibles. function buildCollapsible(key, label, count) { const wrap = document.createElement('div'); @@ -8775,7 +10131,7 @@ void main() { return s.replace(/\s+#.*$/, '').trim(); } - // --- Raw tab: minimal markdown renderer (subset) -------------------------- + // Raw tab: minimal markdown renderer (subset) function renderRawTab(body, md) { const wrap = document.createElement('div'); @@ -8908,9 +10264,9 @@ void main() { } catch { /* ignore */ } } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // function init() { try { history.scrollRestoration = 'manual'; } catch {} diff --git a/.rovodev/skills/impeccable/scripts/live-completion.mjs b/.rovodev/skills/impeccable/scripts/live-completion.mjs index 86b637fff..986773066 100644 --- a/.rovodev/skills/impeccable/scripts/live-completion.mjs +++ b/.rovodev/skills/impeccable/scripts/live-completion.mjs @@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.mode === 'error') return 'error'; + if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error'; return 'agent_done'; } diff --git a/.rovodev/skills/impeccable/scripts/live-inject.mjs b/.rovodev/skills/impeccable/scripts/live-inject.mjs index b9d3df41d..3a1f36e46 100644 --- a/.rovodev/skills/impeccable/scripts/live-inject.mjs +++ b/.rovodev/skills/impeccable/scripts/live-inject.mjs @@ -17,11 +17,38 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './impeccable-paths.mjs'; +import { + applySvelteKitLiveAdapter, + detectSvelteKitProject, + removeSvelteKitLiveAdapter, +} from './live-sveltekit-adapter.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start'; +const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end'; + +export const LIVE_IGNORE_PATTERNS = Object.freeze([ + '.impeccable/hook.cache.json', + '.impeccable/live/server.json', + '.impeccable/live/sessions/', + '.impeccable/live/previews/', + '.impeccable/live/annotations/', + '.impeccable/live/cache/', + '.impeccable/live/manual-edit-apply-transaction.json', + '.impeccable/live/manual-edit-events.jsonl', + '.impeccable/live/manual-edit-evidence/', + '.impeccable/live/pending-manual-edits.json', + '.impeccable/live/deferred-svelte-component-accepts.json', + '.impeccable-live.json', + '.impeccable-live/', + 'node_modules/.impeccable-live/', + 'src/lib/impeccable/ImpeccableLiveRoot.svelte', + 'src/lib/impeccable/__runtime.js', + 'src/lib/impeccable/[0-9a-f]*/', +]); /** * Hard-excluded directory patterns. These are NEVER user-facing pages and @@ -83,8 +110,14 @@ Output (JSON): validateConfig(config); const resolvedFiles = resolveFiles(process.cwd(), config); + const svelteKit = detectSvelteKitProject(process.cwd(), config); if (args.includes('--remove')) { + if (svelteKit) { + const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config }); + console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; @@ -110,6 +143,13 @@ Output (JSON): console.error(JSON.stringify({ ok: false, error: 'missing_port' })); process.exit(1); } + const gitIgnore = ensureLiveGitIgnores(process.cwd()); + + if (svelteKit) { + const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config }); + console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); @@ -129,10 +169,68 @@ Output (JSON): }; }); const anyInserted = results.some((r) => r.inserted); - console.log(JSON.stringify({ ok: anyInserted, port, results })); + console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results })); if (!anyInserted) process.exit(1); } +export function ensureLiveGitIgnores(cwd = process.cwd()) { + const target = resolveIgnoreTarget(cwd); + const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : ''; + const block = [ + IGNORE_MARKER_OPEN, + ...LIVE_IGNORE_PATTERNS, + IGNORE_MARKER_CLOSE, + ].join('\n'); + const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`); + + let updated; + if (markerRe.test(existing)) { + updated = existing.replace(markerRe, block); + } else { + const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n'; + updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`; + } + + if (updated !== existing) { + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + fs.writeFileSync(target.path, updated, 'utf-8'); + } + + return { + file: path.relative(cwd, target.path).split(path.sep).join('/'), + mode: target.mode, + changed: updated !== existing, + patterns: [...LIVE_IGNORE_PATTERNS], + }; +} + +function resolveIgnoreTarget(cwd) { + const gitExcludePath = resolveGitInfoExcludePath(cwd); + if (gitExcludePath) { + return { path: gitExcludePath, mode: 'git-info-exclude' }; + } + return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' }; +} + +function resolveGitInfoExcludePath(cwd) { + const dotGit = path.join(cwd, '.git'); + if (!fs.existsSync(dotGit)) return null; + + const stat = fs.statSync(dotGit); + if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude'); + if (!stat.isFile()) return null; + + const body = fs.readFileSync(dotGit, 'utf-8').trim(); + const match = body.match(/^gitdir:\s*(.+)$/i); + if (!match) return null; + const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]); + return path.join(gitDir, 'info', 'exclude'); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Expand config.files (which may contain glob patterns) into a literal list * of existing file paths relative to rootDir. Literal entries pass through; diff --git a/.rovodev/skills/impeccable/scripts/live-insert.mjs b/.rovodev/skills/impeccable/scripts/live-insert.mjs index 09d4d55be..0658e9914 100644 --- a/.rovodev/skills/impeccable/scripts/live-insert.mjs +++ b/.rovodev/skills/impeccable/scripts/live-insert.mjs @@ -21,6 +21,11 @@ import { buildCssAuthoring, buildCssSelectorPrefixExamples, } from './live-wrap.mjs'; +import { + buildSvelteComponentCssAuthoring, + scaffoldSvelteComponentInsertSession, + shouldUseSvelteComponentInjection, +} from './live-svelte-component.mjs'; const INSERT_POSITIONS = new Set(['before', 'after']); @@ -192,6 +197,41 @@ Output (JSON): const styleMode = detectStyleMode(targetFile); const isJsx = commentSyntax.open === '{/*'; const spliceIndex = computeInsertLine(startLine, endLine, position); + const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); + + if (shouldUseSvelteComponentInjection(targetFile)) { + const session = scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile: relTargetFile, + insertLine: spliceIndex + 1, + position, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + anchorLines: lines.slice(startLine, endLine + 1), + cwd: process.cwd(), + }); + console.log(JSON.stringify({ + mode: 'insert', + position, + file: session.manifestFile, + sourceFile: relTargetFile, + previewMode: 'svelte-component', + componentDir: session.componentDir, + propContract: session.propContract, + insertLine: 1, + sourceInsertLine: spliceIndex + 1, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + commentSyntax, + styleMode: 'svelte-component', + styleTag: null, + cssSelectorPrefixExamples: [], + cssAuthoring: buildSvelteComponentCssAuthoring(count), + })); + return; + } + const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? ''; @@ -216,7 +256,7 @@ Output (JSON): console.log(JSON.stringify({ mode: 'insert', position, - file: path.relative(process.cwd(), targetFile), + file: relTargetFile, insertLine: insertLine + 1, commentSyntax, styleMode: styleMode.mode, diff --git a/.rovodev/skills/impeccable/scripts/live-poll.mjs b/.rovodev/skills/impeccable/scripts/live-poll.mjs index fad836612..1e1259fbc 100644 --- a/.rovodev/skills/impeccable/scripts/live-poll.mjs +++ b/.rovodev/skills/impeccable/scripts/live-poll.mjs @@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs'; // that ceiling and loop in `pollOnce` to synthesize a long poll without // depending on the standalone undici package. export const PER_REQUEST_TIMEOUT_MS = 270_000; +export const DEFAULT_EVENT_LEASE_MS = 600_000; const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); @@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) { ? totalDeadline - Date.now() : PER_REQUEST_TIMEOUT_MS; const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); - const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); + const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`); if (res.status === 401) { const err = new Error('Authentication failed. The server token may have changed.'); @@ -317,7 +318,7 @@ Modes: Options: --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) - --file PATH Attach a source file path to the reply (generate flow) + --file PATH Attach a source file path to the reply (generate/steer flow) --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --help Show this help message diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs index 16c8285b9..cd1091b88 100644 --- a/.rovodev/skills/impeccable/scripts/live-server.mjs +++ b/.rovodev/skills/impeccable/scripts/live-server.mjs @@ -42,6 +42,10 @@ import { } from './live-manual-edits-buffer.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs'; +import { + applyDeferredSvelteComponentAccepts, + removeAllSvelteComponentSessions, +} from './live-svelte-component.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated @@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; +const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2; const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); function tombstoneTimedOutApplyId(eventId, details = {}) { @@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) { return entry.event; } entry.leaseUntil = Date.now() + leaseMs; + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return entry.event; } @@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) { const acknowledged = state.pendingEvents[idx].event; state.pendingEvents.splice(idx, 1); scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return acknowledged; } +function findPendingEventById(id) { + if (!id) return null; + const entry = state.pendingEvents.find((item) => item.event?.id === id); + return entry?.event || null; +} + function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; return `live-poll.mjs --reply ${id} done --data ''`; @@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) { return summary; } +function summarizeActiveSessionForClient(snapshot = {}) { + return { + id: snapshot.id, + phase: snapshot.phase, + pageUrl: snapshot.pageUrl ?? null, + sourceFile: snapshot.sourceFile ?? null, + previewFile: snapshot.previewFile ?? null, + previewMode: snapshot.previewMode ?? null, + expectedVariants: snapshot.expectedVariants ?? 0, + arrivedVariants: snapshot.arrivedVariants ?? 0, + visibleVariant: snapshot.visibleVariant ?? null, + checkpointRevision: snapshot.checkpointRevision ?? 0, + paramValues: snapshot.paramValues || {}, + }; +} + +function activeSessionSummaries() { + if (!state.sessionStore) return []; + return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot)); +} + +function cancelQueuedAnonymousExitEvents() { + let removed = 0; + for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) { + const event = state.pendingEvents[i]?.event; + if (event?.type !== 'exit' || event.id) continue; + state.pendingEvents.splice(i, 1); + removed += 1; + } + if (removed > 0) { + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); + } + return removed; +} + function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { const canceledById = new Map(); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); @@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() { clearTimeout(state.leaseTimer); state.leaseTimer = null; } - if (state.pendingPolls.length === 0) return; const now = Date.now(); const nextLeaseUntil = state.pendingEvents .map((entry) => entry.leaseUntil || 0) @@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() { state.leaseTimer = setTimeout(() => { state.leaseTimer = null; flushPendingPolls(); - }, Math.max(0, nextLeaseUntil - now)); + broadcastAgentPollingIfChanged(); + }, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS)); } function flushPendingPolls() { @@ -1032,7 +1082,9 @@ function flushPendingPolls() { } function agentPollingConnected() { - return state.pendingPolls.length > 0; + const now = Date.now(); + return state.pendingPolls.length > 0 + || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now); } function broadcastAgentPollingIfChanged() { @@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/status') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } - const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; + const sessions = activeSessionSummaries(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', @@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/events' && req.method === 'GET') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + clearTimeout(state.exitTimer); + state.exitTimer = null; + cancelQueuedAnonymousExitEvents(); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { type: 'connected', hasProjectContext: hasProjectContext(), agentPolling: agentPollingConnected(), + activeSessions: activeSessionSummaries(), }) + '\n\n'); state.sseClients.add(res); - clearTimeout(state.exitTimer); // Keepalive: SSE comment every 30s prevents silent connection drops. const heartbeat = setInterval(() => { @@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } } + if (msg.type === 'exit') { + cleanupSvelteComponentSessionsBeforeExit(); + } if (msg.type !== 'checkpoint') { enqueueEvent(msg); } @@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) { }); } +function sessionFileMetadataFromPollReply(file) { + if (!file || typeof file !== 'string') return { file }; + const normalized = file.split(path.sep).join('/'); + const base = { file: normalized }; + if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base; + if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base; + + let full; + try { + full = path.resolve(process.cwd(), normalized); + const rel = path.relative(process.cwd(), full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base; + } catch { + return base; + } + + try { + const manifest = JSON.parse(fs.readFileSync(full, 'utf-8')); + if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base; + return { + file: String(manifest.sourceFile).split(path.sep).join('/'), + sourceFile: String(manifest.sourceFile).split(path.sep).join('/'), + previewFile: normalized, + previewMode: 'svelte-component', + }; + } catch { + return base; + } +} + function handlePollPost(req, res) { let body = ''; req.on('data', (c) => { body += c; }); @@ -1965,6 +2053,16 @@ function handlePollPost(req, res) { res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); return; } + const pendingEventBeforeAck = findPendingEventById(msg.id); + if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done' + && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'steer_done_requires_file_or_message', + hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.', + })); + return; + } const acknowledgedEvent = acknowledgePendingEvent(msg.id); let skipJournalReply = false; let existingSession = null; @@ -1987,6 +2085,7 @@ function handlePollPost(req, res) { })); return; } + const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); if (state.sessionStore && msg.id && !skipJournalReply) { try { const eventType = msg.type === 'steer_done' @@ -2001,7 +2100,10 @@ function handlePollPost(req, res) { state.sessionStore.appendEvent({ type: eventType, id: msg.id, - file: msg.file, + file: replyFileMeta.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, message: msg.message, sourceEventType: acknowledgedEvent?.type, carbonize: msg.data?.carbonize === true, @@ -2010,7 +2112,16 @@ function handlePollPost(req, res) { } flushPendingPolls(); // Forward the reply to the browser via SSE - broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + broadcast({ + type: msg.type || 'done', + id: msg.id, + message: msg.message, + file: msg.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, + data: msg.data, + }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); @@ -2023,6 +2134,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { + cleanupSvelteComponentSessionsBeforeExit(); removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; @@ -2037,6 +2149,25 @@ function shutdown() { process.exit(0); } +function cleanupSvelteComponentSessionsBeforeExit() { + try { + removeAllSvelteComponentSessions(process.cwd()); + } catch (err) { + console.warn('[impeccable] Svelte component session cleanup failed:', err.message); + } +} + +function applyLegacyDeferredAcceptsOnStartup() { + try { + const result = applyDeferredSvelteComponentAccepts(process.cwd()); + if (result.applied > 0 || result.failed > 0) { + console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result)); + } + } catch (err) { + console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({ cwd: process.cwd(), reason: 'manual_edit_server_start_recovered_abandoned_transaction', }); +applyLegacyDeferredAcceptsOnStartup(); restorePendingEventsFromStore(); pruneStaleManualApplyEvidence(process.cwd()); const portArg = args.find(a => a.startsWith('--port=')); diff --git a/.rovodev/skills/impeccable/scripts/live-session-store.mjs b/.rovodev/skills/impeccable/scripts/live-session-store.mjs index 7562e3d5b..5ec4d34d4 100644 --- a/.rovodev/skills/impeccable/scripts/live-session-store.mjs +++ b/.rovodev/skills/impeccable/scripts/live-session-store.mjs @@ -106,6 +106,8 @@ function baseSnapshot(id) { phase: 'new', pageUrl: null, sourceFile: null, + previewFile: null, + previewMode: null, expectedVariants: 0, arrivedVariants: 0, visibleVariant: null, @@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; - next.sourceFile = event.file ?? next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0); next.pendingEventSeq = null; next.pendingEvent = null; if (event.carbonize === true) { @@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { } break; case 'checkpoint': + if (COMPLETED_PHASES.has(next.phase)) { + next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null }); + break; + } if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { next.phase = event.phase ?? next.phase; next.checkpointRevision = event.revision ?? next.checkpointRevision; next.activeOwner = event.owner ?? next.activeOwner; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.visibleVariant = event.visibleVariant ?? next.visibleVariant; + next.sourceFile = event.sourceFile ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; if (event.paramValues) next.paramValues = { ...event.paramValues }; } else { next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); @@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'steer_done': next.phase = 'steer_done'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.message = event.message ?? next.message; next.pendingEventSeq = null; next.pendingEvent = null; break; @@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'complete': next.phase = 'completed'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; next.pendingEventSeq = null; next.pendingEvent = null; break; diff --git a/.rovodev/skills/impeccable/scripts/live-svelte-component.mjs b/.rovodev/skills/impeccable/scripts/live-svelte-component.mjs new file mode 100644 index 000000000..dc35dc0ce --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live-svelte-component.mjs @@ -0,0 +1,826 @@ +/** + * Svelte live-mode component injection helpers. + * + * Variants are real .svelte components under node_modules/.impeccable-live//. + * The browser mounts them via Svelte 5 mount(); accept inlines the chosen + * variant back into the route source with props mapped to original bindings. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; + +export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live'; +export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`; +export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json'; + +const MUSTACHE_RE = /\{([^{}]+)\}/g; + +export function shouldUseSvelteComponentInjection(filePath) { + if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false; + return path.extname(filePath).toLowerCase() === '.svelte'; +} + +export function componentSessionDir(id, cwd = process.cwd()) { + return path.join(cwd, SVELTE_COMPONENT_ROOT, id); +} + +export function manifestPathForSession(id, cwd = process.cwd()) { + return path.join(componentSessionDir(id, cwd), 'manifest.json'); +} + +export function ensureRuntimeHelper(cwd = process.cwd()) { + const file = path.join(cwd, SVELTE_RUNTIME_FILE); + if (fs.existsSync(file)) return file; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8'); + return file; +} + +/** + * Extract ordered unique mustache expressions from markup (not inside ). + */ +export function extractMustacheExpressions(text) { + const expressions = []; + const seen = new Set(); + const lines = String(text || '').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('\n` + : ''; + return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n\n`; +} + +function buildInsertVariantStub(variantNum) { + return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; +} + +export function scaffoldSvelteComponentSession({ + id, + count, + sourceFile, + sourceStartLine, + sourceEndLine, + originalLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const originalMarkup = originalLines.join('\n'); + const contract = buildPropContract(extractMustacheExpressions(originalMarkup)); + const originalWithProps = substituteExprsWithProps(originalMarkup, contract); + + const manifest = { + id, + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + sourceStartLine, + sourceEndLine, + count, + propContract: contract, + originalMarkup, + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: contract, + }; +} + +export function scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile, + insertLine, + position, + anchorStartLine, + anchorEndLine, + anchorLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const anchorMarkup = (anchorLines || []).join('\n'); + const manifest = { + id, + mode: 'insert', + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + insertLine, + position, + anchorStartLine, + anchorEndLine, + originalMarkup: anchorMarkup, + anchorMarkup, + count, + propContract: [], + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: [], + }; +} + +export function findSvelteComponentManifest(id, cwd = process.cwd()) { + const direct = manifestPathForSession(id, cwd); + if (fs.existsSync(direct)) { + return readManifest(direct); + } + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return null; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(root, entry.name, 'manifest.json'); + if (!fs.existsSync(candidate)) continue; + try { + const manifest = readManifest(candidate); + if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; + } catch { /* skip */ } + } + return null; +} + +export function readManifest(manifestPath) { + const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + return { + ...data, + manifestPath, + }; +} + +export function resolveSourceFile(sourceFile, cwd = process.cwd()) { + if (!sourceFile || path.isAbsolute(sourceFile)) { + throw new Error('Invalid svelte-component source file'); + } + const full = path.resolve(cwd, sourceFile); + const rel = path.relative(cwd, full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('Svelte-component source file escapes project root'); + } + if (!fs.existsSync(full)) { + throw new Error('Svelte-component source file not found: ' + sourceFile); + } + return full; +} + +function appendCssToSvelteStyle(lines, cssLines) { + const closeIdx = findLastStyleCloseLine(lines); + const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; + if (closeIdx === -1) { + return [...lines, '', '']; + } + return [ + ...lines.slice(0, closeIdx), + ...prepared, + ...lines.slice(closeIdx), + ]; +} + +function findLastStyleCloseLine(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + if (/<\/style\s*>/.test(lines[i])) return i; + } + return -1; +} + +function bakeParamValuesInCss(cssLines, paramValues) { + if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; + return cssLines.map((line) => { + let out = line; + for (const [key, value] of Object.entries(paramValues)) { + const varName = `--p-${key}`; + out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); + } + return out; + }); +} + +function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { + const css = String((cssLines || []).join('\n')); + if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; + + const rules = parseCssRules(css); + const output = []; + for (const rule of rules) { + appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); + } + return output.join('\n') + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ''); +} + +function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { + const prelude = rule.prelude.trim(); + const body = rule.body.trim(); + if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; + + if (/^@scope\b/i.test(prelude)) { + if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; + const inner = parseCssRules(body); + for (const innerRule of inner) { + const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); + if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; + output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); + } + return; + } + + const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); + if (!rewrittenPrelude) return; + output.push(formatCssRule(rewrittenPrelude, body)); +} + +function parseCssRules(css) { + const rules = []; + const text = String(css || ''); + let i = 0; + while (i < text.length) { + while (i < text.length && /\s/.test(text[i])) i++; + const preludeStart = i; + while (i < text.length && text[i] !== '{') i++; + if (i >= text.length) break; + const prelude = text.slice(preludeStart, i).trim(); + i++; + const bodyStart = i; + let depth = 1; + let quote = null; + let comment = false; + while (i < text.length && depth > 0) { + const ch = text[i]; + const next = text[i + 1]; + if (comment) { + if (ch === '*' && next === '/') { + comment = false; + i += 2; + continue; + } + i++; + continue; + } + if (quote) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === quote) quote = null; + i++; + continue; + } + if (ch === '/' && next === '*') { + comment = true; + i += 2; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); + if (prelude) rules.push({ prelude, body }); + } + return rules; +} + +function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { + const selectors = splitSelectorList(prelude); + const rewritten = []; + for (const selector of selectors) { + const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); + if (next) rewritten.push(next); + } + return rewritten.join(', '); +} + +function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { + let out = selector.trim(); + const hasVariant = /data-impeccable-variant/.test(out); + if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; + if (hasVariant) { + out = out.replace(variantSelectorRegex(variantNum), ''); + out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); + } + + const paramResult = rewriteParamSelectors(out, paramValues); + if (!paramResult.keep) return ''; + out = paramResult.selector; + + out = out + .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') + .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') + .replace(/\s+/g, ' ') + .trim(); + + out = out.replace(/^[>+~]\s*/, '').trim(); + if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; + return out; +} + +function rewriteParamSelectors(selector, paramValues) { + let keep = true; + const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { + if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; + const actual = paramValues[key]; + if (expected != null && String(actual) !== String(expected)) { + keep = false; + return ''; + } + if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { + keep = false; + return ''; + } + return ''; + }); + return { keep, selector: next }; +} + +function splitSelectorList(prelude) { + const selectors = []; + let start = 0; + let bracket = 0; + let paren = 0; + let quote = null; + for (let i = 0; i < prelude.length; i++) { + const ch = prelude[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '[') bracket++; + else if (ch === ']') bracket = Math.max(0, bracket - 1); + else if (ch === '(') paren++; + else if (ch === ')') paren = Math.max(0, paren - 1); + else if (ch === ',' && bracket === 0 && paren === 0) { + selectors.push(prelude.slice(start, i)); + start = i + 1; + } + } + selectors.push(prelude.slice(start)); + return selectors; +} + +function selectorHasVariant(selector, variantNum) { + return variantSelectorRegex(variantNum).test(selector); +} + +function variantSelectorRegex(variantNum) { + return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); +} + +function formatCssRule(selector, body) { + return `${selector} { ${body.trim()} }`; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { + const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); + const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); + const resultBase = { + file: manifest.sourceFile, + sourceFile: manifest.sourceFile, + previewMode: 'svelte-component', + componentDir: manifest.componentDir, + carbonize: false, + }; + if (!fs.existsSync(variantPath)) { + return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; + } + + const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); + if (manifest.mode === 'insert') { + return inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, + }); + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const contract = manifest.propContract || []; + const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); + const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract) + .split('\n') + .map((line) => line.trimEnd()); + + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const start = Number(manifest.sourceStartLine) - 1; + const end = Number(manifest.sourceEndLine) - 1; + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { + return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; + } + + const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, start), + ...indentedMarkup, + ...sourceLines.slice(end + 1), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, +}) { + if (!svelteMarkupHasVisibleContent(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; + } + if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const restoredMarkup = String(markup || '') + .split('\n') + .map((line) => line.trimEnd()); + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const insertIndex = Number(manifest.insertLine) - 1; + if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { + return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; + } + + const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; + const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, insertIndex), + ...indentedMarkup, + ...sourceLines.slice(insertIndex), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function svelteMarkupHasVisibleContent(markup) { + const text = String(markup || '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 0) return true; + return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); +} + +function mergeOriginalTopLevelAttrs(markup, originalMarkup) { + const variantOpen = matchOpeningTag(markup); + const originalOpen = matchOpeningTag(originalMarkup); + if (!variantOpen || !originalOpen) return markup; + if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; + + const variantAttrs = parseAttrSegments(variantOpen.attrs); + const originalAttrs = parseAttrSegments(originalOpen.attrs); + const additions = []; + let attrs = variantOpen.attrs; + + const originalClass = originalAttrs.get('class'); + const variantClass = variantAttrs.get('class'); + if (originalClass && variantClass) { + const merged = mergeStaticClassAttr(originalClass, variantClass); + if (merged) { + attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); + variantAttrs.set('class', { ...variantClass, raw: merged }); + } + } else if (originalClass && !variantClass) { + additions.push(originalClass.raw); + } + + for (const [name, attr] of originalAttrs) { + if (name === 'class') continue; + if (!variantAttrs.has(name)) additions.push(attr.raw); + } + + if (additions.length === 0 && attrs === variantOpen.attrs) return markup; + const nextOpen = variantOpen.prefix + + variantOpen.tag + + attrs + + additions.map((attr) => ' ' + attr.trim()).join('') + + variantOpen.close; + return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); +} + +function matchOpeningTag(markup) { + const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); + if (!match) return null; + return { + raw: match[0], + prefix: match[1], + tag: match[2], + attrs: match[3] || '', + close: match[4], + index: match.index || 0, + }; +} + +function parseAttrSegments(attrs) { + const out = new Map(); + const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; + let match; + while ((match = re.exec(attrs))) { + const raw = match[0]; + const name = match[1]; + out.set(name, { + name, + raw, + start: match.index, + end: match.index + raw.length, + }); + } + return out; +} + +function mergeStaticClassAttr(originalClass, variantClass) { + const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + if (!originalValue || !variantValue) return null; + const quote = variantValue[1]; + const classes = [ + ...variantValue[2].split(/\s+/), + ...originalValue[2].split(/\s+/), + ].filter(Boolean); + return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; +} + +export function removeSvelteComponentSession(id, cwd = process.cwd()) { + const dir = componentSessionDir(id, cwd); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* non-fatal */ } +} + +export function removeAllSvelteComponentSessions(cwd = process.cwd()) { + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('__')) continue; + try { + fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + } catch { /* non-fatal */ } + } +} + +export function deferredAcceptsPath(cwd = process.cwd()) { + const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16); + return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json'); +} + +export function readDeferredAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch { + return { accepts: [] }; + } +} + +export function writeDeferredAccept(entry, cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const data = readDeferredAccepts(cwd); + data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id); + data.accepts.push({ ...entry, createdAt: new Date().toISOString() }); + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + const data = readDeferredAccepts(cwd); + const pending = Array.isArray(data.accepts) ? data.accepts : []; + const results = []; + const remaining = []; + for (const entry of pending) { + try { + const manifest = findSvelteComponentManifest(entry.id, cwd); + if (!manifest) { + results.push({ id: entry.id, ok: false, error: 'manifest not found' }); + remaining.push(entry); + continue; + } + const result = inlineSvelteComponentAccept( + manifest, + entry.variantNum, + entry.paramValues || null, + cwd, + ); + results.push({ id: entry.id, ok: result.handled !== false, result }); + if (result.handled === false) remaining.push(entry); + } catch (err) { + results.push({ id: entry.id, ok: false, error: err.message }); + remaining.push(entry); + } + } + if (remaining.length > 0) { + fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8'); + } else { + try { fs.rmSync(file, { force: true }); } catch {} + } + return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results }; +} + +export function buildSvelteComponentCssAuthoring(count) { + const variantNumbers = Array.from({ length: count }, (_, i) => i + 1); + return { + mode: 'svelte-component', + styleTag: null, + strategy: 'component-style-block', + rulePattern: '.semantic-class { ... }', + selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'), + requirements: [ + 'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).', + 'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.', + 'Put variant CSS in the component ' : '')); + if (paramValues && Object.keys(paramValues).length > 0) { + lines.push( + bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close, + ); + } + lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + lines.push(bodyIndent + '
'); + lines.push(...bodyRestored); + lines.push(bodyIndent + '
'); + }; + + if (isJsx) { + const wrapperStyle = 'style={{ display: "contents" }}'; + lines.push(indent + '
'); + pushCarbonizeBody(indent + ' '); + lines.push(indent + '
'); + } else { + pushCarbonizeBody(indent); + } + + return lines; +} + +function reindentContent(contentLines, fromIndent, toIndent) { + return contentLines.map((line) => { + if (line.trim() === '') return ''; + if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length); + return toIndent + line.trimStart(); + }); +} + function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; @@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const needsCarbonize = !!(cssContent || hasHelperAttrs); - // Build the replacement const restored = deindentContent(variantContent, indent); - const replacement = []; - - if (cssContent) { - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); - // JSX targets need the CSS body wrapped in a template literal so that the - // `{` and `}` in CSS rules don't get parsed as JSX expressions. - replacement.push(indent + '' : '')); - if (paramValues && Object.keys(paramValues).length > 0) { - // Preserve the user's knob positions for the carbonize-cleanup agent - // to bake into the final CSS when it collapses scoped rules. - replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); - } - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); - } - - // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the - // carbonize CSS block working visually by re-wrapping the accepted content - // in a data-impeccable-variant="N" div with `display: contents` (so layout - // isn't affected). The carbonize agent strips this attribute + wrapper when - // it moves the CSS to a proper stylesheet. - // - // Style attribute syntax has to follow the host file's flavor — JSX files - // need the object form, otherwise React 19 throws "Failed to set indexed - // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. - if (cssContent) { - const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; - replacement.push(indent + '
'); - replacement.push(...restored); - replacement.push(indent + '
'); - } else { - replacement.push(...restored); - } + const replacement = buildCarbonizeReplacement({ + indent, + commentSyntax, + isJsx, + id, + variantNum, + cssContent, + paramValues, + restored, + }); const newLines = [ ...lines.slice(0, replaceRange.start), @@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; } +function readSourceShadowPreviewMeta(content, id) { + const escaped = escapeRegExp(id); + const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>'); + const match = String(content || '').match(wrapperRe); + if (!match) return null; + const tag = match[0]; + if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null; + const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file'); + const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start')); + const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end')); + if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null; + return { sourceFile, sourceStartLine, sourceEndLine }; +} + +function readHtmlAttr(tag, name) { + const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1')); + if (!match) return null; + return decodeHtmlAttr(match[2]); +} + +function decodeHtmlAttr(value) { + return String(value || '') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- @@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts }; diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index f609ac847..d7c580e2f 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -26,9 +26,9 @@ return; } - // --------------------------------------------------------------------------- + // // Design tokens - // --------------------------------------------------------------------------- + // // Brand kinpaku (gold) is pinned to the site's neo-kinpaku tokens // (see site/styles/kinpaku-tokens.css) so Accept / knobs / cycle-dots / @@ -115,19 +115,48 @@ { value: 'overdrive', label: 'Overdrive' }, ]; - // --------------------------------------------------------------------------- + const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions']; + const LIVE_UI_SURFACES = [ + { 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'] }, + { key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] }, + { key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-configure-input-wrap', PREFIX + '-input', PREFIX + '-configure-voice'] }, + { key: 'action-picker', ids: [PREFIX + '-picker'] }, + { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, + { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, + { key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] }, + { key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] }, + { key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] }, + { 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'] }, + { key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] }, + { key: 'design-system-panel', ids: [PREFIX + '-design-host'] }, + { key: 'toasts-and-errors', ids: [PREFIX + '-toast'] }, + { key: 'css-isolation-boundary', ids: [PREFIX + '-root'] }, + ]; + const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))]; + + // // State - // --------------------------------------------------------------------------- + // let state = 'IDLE'; let hoveredElement = null; let selectedElement = null; let currentSessionId = null; - let pendingAcceptedSession = null; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; + let svelteComponentSession = null; + let svelteRuntimePromise = null; + let pendingSvelteComponentRetryObserver = null; + let currentSourceFile = null; + let currentPreviewFile = null; + let currentPreviewMode = null; + let recoveryWaitingForAnchor = false; + let pendingAcceptedSession = null; let variantObserver = null; + let variantSelectionInFlight = false; + let variantSelectionPromise = null; + let recoveringEmptyCycling = false; let hasProjectContext = false; let selectedAction = 'impeccable'; let selectedCount = 3; @@ -175,14 +204,17 @@ let highlightEl = null; let tooltipEl = null; let barEl = null; + let barHideSeq = 0; let pickerEl = null; let toastEl = null; let scrollRaf = null; let editBadgeEl = null; + let editBadgeProxyRoot = null; + let editBadgeProxyByTarget = new Map(); - // --------------------------------------------------------------------------- + // // Helpers - // --------------------------------------------------------------------------- + // function own(el) { return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); @@ -204,8 +236,105 @@ return s; } + function rectIsUsableAnchor(rect) { + return !!rect && rect.width > 0.5 && rect.height > 0.5; + } + + function makeFrozenAnchor(el) { + if (!el || !el.getBoundingClientRect) return null; + const r = el.getBoundingClientRect(); + if (!rectIsUsableAnchor(r)) return null; + const rect = { + x: r.x, y: r.y, + top: r.top, left: r.left, + right: r.right, bottom: r.bottom, + width: r.width, height: r.height, + }; + return { + __impeccableFrozenAnchor: true, + tagName: el.tagName || 'DIV', + id: el.id || '', + classList: el.classList ? [...el.classList] : [], + hasAttribute: () => false, + getBoundingClientRect: () => rect, + }; + } + function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } + function cssId(id) { + if (window.CSS?.escape) return CSS.escape(id); + return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); + } + + function liveUiRoot() { + const root = window.__IMPECCABLE_LIVE_UI_ROOT__; + if (root && typeof root.appendChild === 'function') return root; + return document.body; + } + + function uiAppend(el) { + liveUiRoot().appendChild(el); + return el; + } + + function uiAppendStyle(styleEl) { + const root = liveUiRoot(); + if (root && root !== document.body) root.appendChild(styleEl); + else document.head.appendChild(styleEl); + return styleEl; + } + + function uiGetById(id) { + const root = liveUiRoot(); + if (root?.getElementById) { + const found = root.getElementById(id); + if (found) return found; + } + if (root?.querySelector) { + const found = root.querySelector('#' + cssId(id)); + if (found) return found; + } + return document.getElementById(id); + } + + function activeElementDeep() { + let active = document.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; + return active; + } + + window.__IMPECCABLE_LIVE_CHROME_CORE__ = { + version: 1, + adapter: window.__IMPECCABLE_LIVE_ADAPTER__ || 'dom', + mountContract: LIVE_CHROME_MOUNT_CONTRACT, + surfaces: LIVE_UI_SURFACES, + componentIds: LIVE_UI_COMPONENT_IDS, + root: liveUiRoot, + append: uiAppend, + appendStyle: uiAppendStyle, + getById: uiGetById, + activeElementDeep, + debugState: () => ({ + state, + currentSessionId, + expectedVariants, + arrivedVariants, + visibleVariant, + savedSession: loadSession(), + sourceFile: currentSourceFile, + previewFile: currentPreviewFile, + previewMode: currentPreviewMode, + barText: barEl?.textContent || null, + barConnected: !!barEl?.isConnected, + hasSvelteComponentSession: !!svelteComponentSession, + mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0, + pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver, + recoveryWaitingForAnchor, + evtSourceReadyState: evtSource ? evtSource.readyState : null, + }), + }; + // Modal-aware chrome: keep our floating UI clickable inside Radix / // Headless UI / vaul portals. // @@ -245,9 +374,9 @@ rootEl.addEventListener('focusin', stop); } - // --------------------------------------------------------------------------- + // // Highlight overlay - // --------------------------------------------------------------------------- + // function initHighlight() { highlightEl = document.createElement('div'); @@ -259,7 +388,7 @@ transition: HIGHLIGHT_TRANSITION, display: 'none', opacity: '0', }); - document.body.appendChild(highlightEl); + uiAppend(highlightEl); tooltipEl = document.createElement('div'); tooltipEl.id = PREFIX + '-tooltip'; @@ -273,7 +402,7 @@ letterSpacing: '0.02em', transition: TOOLTIP_TRANSITION, }); - document.body.appendChild(tooltipEl); + uiAppend(tooltipEl); } function showHighlight(el) { @@ -310,7 +439,7 @@ if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; } } - // --------------------------------------------------------------------------- + // // Annotation overlay (comment pins + kinpaku strokes) // // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned @@ -318,7 +447,7 @@ // drag) drops a comment pin; drag paints a kinpaku SVG stroke. All coords // are stored in element-local CSS px so they survive scroll / resize and // correlate directly with the captured PNG. - // --------------------------------------------------------------------------- + // const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it @@ -397,7 +526,7 @@ annotOverlayEl.addEventListener('pointermove', onAnnotMove); annotOverlayEl.addEventListener('pointerup', onAnnotUp); annotOverlayEl.addEventListener('pointercancel', onAnnotUp); - document.body.appendChild(annotOverlayEl); + uiAppend(annotOverlayEl); // Modal-host friendliness: pointer-events is already 'auto' on this // overlay; we only need to silence the host's outside-interaction // listeners. Don't override pointer-events here (the overlay toggles @@ -828,9 +957,9 @@ return wrap; } - // --------------------------------------------------------------------------- + // // Element context extraction - // --------------------------------------------------------------------------- + // function stripManualEditRuntimeState(root) { if (!root || root.nodeType !== 1) return; @@ -971,9 +1100,9 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } - // --------------------------------------------------------------------------- + // // The Bar - one floating element, three modes - // --------------------------------------------------------------------------- + // // Contextual-bar palette. Cached at init so every build*Row reads a // consistent set of colors; detectPageTheme runs once rather than on every @@ -1006,7 +1135,7 @@ padding: '6px', maxWidth: '520px', minWidth: '320px', }); - document.body.appendChild(barEl); + uiAppend(barEl); defangOutsideHandlers(barEl); } @@ -1041,6 +1170,8 @@ } function showBar(mode) { + barHideSeq += 1; + if (mode === 'cycling' && !ensureCyclingRenderable('show-bar')) return; barEl.innerHTML = ''; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); @@ -1058,11 +1189,12 @@ function hideBar() { if (!barEl) return; + const hideSeq = ++barHideSeq; stopVoice({ suppressSubmit: true }); if (configureKind === 'insert') clearInsertPicking(); barEl.style.opacity = '0'; barEl.style.transform = 'translateY(6px)'; - setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); + setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); if (state === 'EDITING') restoreInlineEditDrafts(); @@ -1071,6 +1203,7 @@ function updateBarContent(mode) { if (!barEl || barEl.style.display === 'none') return; + if (mode === 'cycling' && !ensureCyclingRenderable('update-bar')) return; barEl.innerHTML = ''; // Reset bar styling to the kinpaku picker palette barEl.style.background = BP.surface; @@ -1090,13 +1223,13 @@ syncPageChatFocus('update-bar-content'); } - // --- Configure row --- + // Configure row function syncConfigureInputChrome() { - const wrap = document.getElementById(PREFIX + '-configure-input-wrap'); - const input = document.getElementById(PREFIX + '-input'); + const wrap = uiGetById(PREFIX + '-configure-input-wrap'); + const input = uiGetById(PREFIX + '-input'); if (!wrap || !input) return; - const focused = document.activeElement === input; + const focused = activeElementDeep() === input; wrap.dataset.inputFocused = focused ? 'true' : 'false'; wrap.dataset.voiceListening = (voiceListening && voiceCtx?.mode === 'configure') ? 'true' : 'false'; wrap.style.borderColor = (voiceListening && voiceCtx?.mode === 'configure') @@ -1104,7 +1237,7 @@ : (focused ? BP.accentSoft : BP.hairline); } - // --- Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) --- + // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) function detectInsertAxisFromStyle(style) { const display = style?.display || 'block'; @@ -1377,7 +1510,7 @@ display: 'none', opacity: '0.9', }); - document.body.appendChild(insertLineEl); + uiAppend(insertLineEl); defangOutsideHandlers(insertLineEl); return insertLineEl; } @@ -1440,6 +1573,10 @@ /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { + if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor) return anchor; + } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (wrapper) { @@ -1557,6 +1694,11 @@ positionBar(); } + function showOrUpdateCyclingBar() { + if (barEl && barEl.style.display !== 'none') updateBarContent('cycling'); + else showBar('cycling'); + } + function buildPlaceholderResizeHandles() { if (!placeholderResizeLayerEl) return; placeholderResizeLayerEl.innerHTML = ''; @@ -1665,7 +1807,7 @@ } function isInsertCreateEnabled(btn) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); + btn = btn || uiGetById(PREFIX + '-insert-create'); return !!btn && btn.getAttribute('aria-disabled') !== 'true'; } @@ -1691,7 +1833,7 @@ lineHeight: '1.35', }); insertCreateTooltipEl.id = PREFIX + '-insert-create-tooltip'; - document.body.appendChild(insertCreateTooltipEl); + uiAppend(insertCreateTooltipEl); return insertCreateTooltipEl; } @@ -1723,8 +1865,8 @@ } function syncInsertCreateButton(btn, input) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); - input = input || document.getElementById(PREFIX + '-insert-input'); + btn = btn || uiGetById(PREFIX + '-insert-create'); + input = input || uiGetById(PREFIX + '-insert-input'); if (!btn || !input) return; const gate = insertCreateGateState(input); const ok = canCreateInsert(gate); @@ -1833,7 +1975,7 @@ voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!document.getElementById(PREFIX + '-configure-input-style')) { + if (!uiGetById(PREFIX + '-configure-input-style')) { const s = document.createElement('style'); s.id = PREFIX + '-configure-input-style'; s.textContent = @@ -1842,7 +1984,7 @@ '#' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: impeccable-configure-voice-pulse 1.1s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-configure-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } input.addEventListener('focus', () => syncConfigureInputChrome()); @@ -1949,6 +2091,9 @@ transition: 'border-color 0.15s ease', }); inputWrap.id = PREFIX + '-insert-input-wrap'; + inputWrap.addEventListener('pointerdown', (e) => e.stopPropagation()); + inputWrap.addEventListener('mousedown', (e) => e.stopPropagation()); + inputWrap.addEventListener('click', (e) => e.stopPropagation()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; @@ -1984,6 +2129,12 @@ voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; input.addEventListener('input', () => syncInsertCreateButton()); + input.addEventListener('pointerdown', (e) => e.stopPropagation()); + input.addEventListener('mousedown', (e) => e.stopPropagation()); + input.addEventListener('click', (e) => { + e.stopPropagation(); + try { input.focus({ preventScroll: true }); } catch { input.focus(); } + }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); @@ -2049,6 +2200,7 @@ }); create.addEventListener('mouseleave', hideInsertCreateTooltip); create.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); if (controlsLocked) { showManualApplyBusyToast(); return; } if (!isInsertCreateEnabled(create)) return; @@ -2060,7 +2212,7 @@ return row; } - // --- Generating row --- + // Generating row function buildGeneratingRow() { const row = el('div', { @@ -2086,19 +2238,24 @@ }); // Variants currently arrive atomically in a single file edit, so a // per-variant counter would lie. Say what's true. - status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + expectedVariants + ' variants...' - : 'Done'; + status.textContent = recoveryWaitingForAnchor + ? 'Variants ready. Reveal the selected element to resume.' + : (arrivedVariants < expectedVariants + ? 'Generating ' + expectedVariants + ' variants...' + : 'Done'); row.appendChild(status); return row; } - // --- Cycling row --- + // Cycling row const TUNE_ICON_SVG = ''; function buildCyclingRow() { + if (!ensureCyclingRenderable('build-cycling-row')) { + return el('div', { display: 'none' }); + } const row = el('div', { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 2px', @@ -2106,6 +2263,7 @@ // Prev const prev = navBtn('\u2190'); + prev.id = PREFIX + '-variant-prev'; prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); }); if (visibleVariant <= 1) prev.style.opacity = '0.3'; row.appendChild(prev); @@ -2118,11 +2276,13 @@ fontFamily: MONO, fontSize: '11px', fontWeight: '500', color: BP.textDim, minWidth: '24px', textAlign: 'center', }); + counter.id = PREFIX + '-variant-counter'; counter.textContent = visibleVariant + '/' + arrivedVariants; row.appendChild(counter); // Next const next = navBtn('\u2192'); + next.id = PREFIX + '-variant-next'; next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); }); if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3'; row.appendChild(next); @@ -2208,9 +2368,9 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders - // --- Saving row (waiting for agent to process accept/discard) --- + // Saving row (waiting for agent to process accept/discard) function buildSavingRow() { const row = el('div', { @@ -2235,7 +2395,7 @@ return row; } - // --- Confirmed row (green success, auto-dismisses) --- + // Confirmed row (green success, auto-dismisses) function buildConfirmedRow() { const row = el('div', { @@ -2256,7 +2416,7 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders function buildDots(clickable) { const container = el('div', { @@ -2290,10 +2450,7 @@ const idx = i; dot.addEventListener('click', (e) => { e.stopPropagation(); - visibleVariant = idx; - showVariantInDOM(currentSessionId, idx); - updateSelectedElement(); - updateBarContent('cycling'); + selectVariant(idx, 'variant_changed'); }); } container.appendChild(dot); @@ -2323,13 +2480,14 @@ function el(tag, styles) { const e = document.createElement(tag); + if (String(tag).toLowerCase() === 'button') e.type = 'button'; if (styles) Object.assign(e.style, styles); return e; } - // --------------------------------------------------------------------------- + // // Action picker popover - // --------------------------------------------------------------------------- + // function initActionPicker() { const P = barPaletteForTheme(detectPageTheme()); @@ -2384,16 +2542,20 @@ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent'; }); chip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + const prompt = uiGetById(PREFIX + '-input')?.value || ''; selectedAction = action.value; hideActionPicker(); updateBarContent('configure'); + const input = uiGetById(PREFIX + '-input'); + if (input && prompt) input.value = prompt; }); grid.appendChild(chip); }); pickerEl.appendChild(grid); - document.body.appendChild(pickerEl); + uiAppend(pickerEl); defangOutsideHandlers(pickerEl); // Cache the palette on the picker so toggleActionPicker's state refresh @@ -2433,7 +2595,33 @@ setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180); } - // --------------------------------------------------------------------------- + function ensureCyclingRenderable(reason) { + if (arrivedVariants > 0) { + if (visibleVariant < 1 || visibleVariant > arrivedVariants) visibleVariant = 1; + return true; + } + recoverEmptyCycling(reason); + return false; + } + + function recoverEmptyCycling(reason) { + if (recoveringEmptyCycling) return; + recoveringEmptyCycling = true; + try { + console.warn('[impeccable] Refusing to render empty variant cycling state:', reason); + const message = 'No variants were mounted. Please try again.'; + if (svelteComponentSession?.sessionId === currentSessionId) { + abortSvelteComponentInjection(currentSessionId, message); + return; + } + cleanup(); + showToast(message, 5000); + } finally { + recoveringEmptyCycling = false; + } + } + + // // Params panel (per-variant coarse controls) // // Variants may declare a parameter manifest via a JSON attribute on the @@ -2446,13 +2634,13 @@ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped // CSS can respond instantly without regeneration: // - // range / numeric toggle → CSS var (`--p-`) used via var(--p-foo, N) + // range / numeric toggle -> CSS custom property used by variant styles // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."] // // On variant switch, values reset to that variant's declared defaults. // On accept, current values are sent in the event payload so the agent // can bake them into the source-file write. - // --------------------------------------------------------------------------- + // let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide) let paramsPanelInner = null; // translating content (carries bg, padding, knobs) @@ -2507,7 +2695,7 @@ }); paramsPanelEl.appendChild(paramsPanelBody); - document.body.appendChild(paramsPanelEl); + uiAppend(paramsPanelEl); // Don't override pointer-events: the panel toggles between 'none' (closed, // click-through) and 'auto' (open) on its own. Just silence the host's // outside-interaction listeners while the panel is open. @@ -2516,14 +2704,40 @@ } + function getMountedSvelteComponentAnchor(session = svelteComponentSession) { + const el = session?.mountTargetEl?.firstElementChild || null; + if (!el || !document.body.contains(el)) return null; + return rectIsUsableAnchor(el.getBoundingClientRect()) ? el : null; + } + + function resolveSvelteComponentAnchor(session = svelteComponentSession) { + return getMountedSvelteComponentAnchor(session) + || session?.swapAnchor + || null; + } + function getVisibleVariantEl() { if (!currentSessionId) return null; + if (svelteComponentSession?.sessionId === currentSessionId) { + return resolveSvelteComponentAnchor() + || svelteComponentSession.wrapperEl + || null; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } function parseVariantParams(variantEl) { + // Svelte component variants can't carry a `data-impeccable-params` attribute: + // the compiler reads `{` inside attribute values as expression delimiters, so + // JSON-with-braces breaks the build. For that path the params live in a sidecar + // params.json keyed by variant number, loaded into the session at mount time. + if (svelteComponentSession?.sessionId === currentSessionId) { + const byVariant = svelteComponentSession.paramsByVariant || {}; + const params = byVariant[String(visibleVariant)] || byVariant[visibleVariant]; + return Array.isArray(params) ? params : []; + } if (!variantEl) return []; const raw = variantEl.getAttribute('data-impeccable-params'); if (!raw) return []; @@ -2685,11 +2899,11 @@ } } - // --------------------------------------------------------------------------- + // // Inline text editing - makes pure-text descendants of the picked element // directly contenteditable. Save stages copy edits in the live buffer; the // Apply copy edits dock later asks the AI to apply the staged batch. - // --------------------------------------------------------------------------- + // let inlineEditRows = []; let inlineEditDrafts = new Map(); @@ -2803,7 +3017,7 @@ function disableInlineEdit(opts = {}) { for (const row of inlineEditRows) { - if (document.activeElement === row.el) row.el.blur(); + if (activeElementDeep() === row.el) row.el.blur(); row.el.removeAttribute('contenteditable'); delete row.el.dataset.impeccableEditable; delete row.el.dataset.impeccableOriginalText; @@ -3133,7 +3347,7 @@ if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) { showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); } else { - showToast('Save failed: retry or cancel', 4000); + showToast('Save failed - retry or cancel', 4000); } } } @@ -3181,11 +3395,11 @@ } function ensureSpinKeyframes() { - if (document.getElementById(PREFIX + '-keyframes')) return; + if (uiGetById(PREFIX + '-keyframes')) return; const style = document.createElement('style'); style.id = PREFIX + '-keyframes'; style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }'; - document.head.appendChild(style); + uiAppendStyle(style); } function pendingApplyLabel(count) { @@ -3318,10 +3532,10 @@ closeTunePopover(); } if (barEl && barEl.style.display !== 'none' && state === 'CONFIGURING') { - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value : ''; updateBarContent('configure'); - const nextInput = document.getElementById(PREFIX + '-input'); + const nextInput = uiGetById(PREFIX + '-input'); if (nextInput) nextInput.value = prompt; } if (editBadgeEl && editBadgeEl.style.display !== 'none') { @@ -3455,19 +3669,19 @@ updatePendingCounter(remaining); if (result.failed && result.failed.length > 0) { console.warn('[impeccable] some copy edits failed:', result.failed); - showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed - see console', 5000); } else { const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0); if (n > 0) { showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); } else { console.warn('[impeccable] apply returned no verified edits:', result); - showToast('No edits applied, see console', 4000); + showToast('No edits applied - see console', 4000); } } } catch (err) { console.error('[impeccable] commit failed:', err); - showToast('Apply failed, see console', 4000); + showToast('Apply failed - see console', 4000); } finally { if (waitForSseCompletion) return; const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0; @@ -3497,7 +3711,7 @@ } } catch (err) { console.error('[impeccable] discard failed:', err); - showToast('Discard failed, see console', 4000); + showToast('Discard failed - see console', 4000); } } @@ -3645,7 +3859,7 @@ const failedCount = numberOrNull(msg.failedCount) || 0; const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0; if (failedCount > 0) { - showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000); + showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed - see console', 5000); } else if (appliedCount > 0) { showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500); } @@ -3799,9 +4013,164 @@ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); } - // --------------------------------------------------------------------------- + // // Edit content badge - floating button at element top-right to enter EDITING mode - // --------------------------------------------------------------------------- + // + + function usesShadowChromeRoot() { + const root = liveUiRoot(); + return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; + } + + function setImportantStyle(el, name, value) { + el.style.setProperty(name, value, 'important'); + } + + function initEditBadgeHitProxies() { + if (!usesShadowChromeRoot() || editBadgeProxyRoot) return; + editBadgeProxyRoot = document.createElement('div'); + editBadgeProxyRoot.id = PREFIX + '-edit-badge-hit-proxies'; + editBadgeProxyRoot.setAttribute('aria-hidden', 'true'); + const styles = { + all: 'initial', + position: 'fixed', + inset: '0', + width: '100vw', + height: '100vh', + zIndex: String(Z.toast + 1), + pointerEvents: 'none', + background: 'transparent', + overflow: 'visible', + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + document.body.appendChild(editBadgeProxyRoot); + } + + function styleEditBadgeProxy(proxy, target) { + const rect = target.getBoundingClientRect(); + const cursor = getComputedStyle(target).cursor || 'pointer'; + const styles = { + all: 'initial', + position: 'fixed', + left: rect.left + 'px', + top: rect.top + 'px', + width: rect.width + 'px', + height: rect.height + 'px', + margin: '0', + padding: '0', + border: '0', + borderRadius: '0', + background: 'transparent', + color: 'transparent', + opacity: '0.001', + pointerEvents: 'auto', + cursor, + zIndex: String(Z.toast + 2), + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(proxy, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + } + + function proxyMouseEvent(type, source, target) { + let event; + try { + event = new MouseEvent(type, { + bubbles: type !== 'mouseenter' && type !== 'mouseleave', + cancelable: true, + composed: true, + clientX: source.clientX, + clientY: source.clientY, + screenX: source.screenX, + screenY: source.screenY, + button: source.button || 0, + buttons: source.buttons || 0, + ctrlKey: source.ctrlKey, + metaKey: source.metaKey, + shiftKey: source.shiftKey, + altKey: source.altKey, + }); + target.dispatchEvent(event); + } catch {} + } + + function bindEditBadgeProxy(proxy, target) { + const stop = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + proxy.addEventListener('mouseenter', (event) => { + stop(event); + proxyMouseEvent('mouseenter', event, target); + proxyMouseEvent('mouseover', event, target); + }); + proxy.addEventListener('mouseleave', (event) => { + stop(event); + proxyMouseEvent('mouseleave', event, target); + proxyMouseEvent('mouseout', event, target); + }); + proxy.addEventListener('mousedown', (event) => { + stop(event); + target.focus?.({ preventScroll: true }); + proxyMouseEvent('mousedown', event, target); + }); + proxy.addEventListener('mouseup', (event) => { + stop(event); + proxyMouseEvent('mouseup', event, target); + }); + proxy.addEventListener('click', (event) => { + stop(event); + target.click(); + syncEditBadgeHitProxies(); + }); + } + + function editBadgeProxyTargets() { + if (!usesShadowChromeRoot() || !editBadgeEl || editBadgeEl.style.display === 'none') return []; + return [...editBadgeEl.querySelectorAll('button')].filter((target) => { + if (target.disabled) return false; + const rect = target.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return false; + const style = getComputedStyle(target); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); + } + + function syncEditBadgeHitProxies() { + if (!usesShadowChromeRoot()) { + if (editBadgeProxyRoot) editBadgeProxyRoot.remove(); + editBadgeProxyRoot = null; + editBadgeProxyByTarget = new Map(); + return; + } + initEditBadgeHitProxies(); + if (!editBadgeProxyRoot) return; + const targets = editBadgeProxyTargets(); + const active = new Set(targets); + for (const [target, proxy] of editBadgeProxyByTarget) { + if (!active.has(target) || !target.isConnected) { + proxy.remove(); + editBadgeProxyByTarget.delete(target); + } + } + for (const target of targets) { + let proxy = editBadgeProxyByTarget.get(target); + if (!proxy) { + proxy = document.createElement('button'); + proxy.type = 'button'; + proxy.tabIndex = -1; + proxy.dataset.impeccableEditBadgeProxy = 'true'; + proxy.setAttribute('aria-hidden', 'true'); + bindEditBadgeProxy(proxy, target); + editBadgeProxyRoot.appendChild(proxy); + editBadgeProxyByTarget.set(target, proxy); + } + proxy.title = target.title || target.textContent || 'Edit copy'; + styleEditBadgeProxy(proxy, target); + } + } function initEditBadge() { editBadgeEl = document.createElement('div'); @@ -3813,10 +4182,11 @@ display: 'none', userSelect: 'none', }); - document.body.appendChild(editBadgeEl); + uiAppend(editBadgeEl); + initEditBadgeHitProxies(); // Remove focus rings on edit badge buttons + contenteditable elements - if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + if (!uiGetById(PREFIX + '-edit-badge-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-edit-badge-focus-style'; s.textContent = @@ -3826,21 +4196,26 @@ '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; - document.head.appendChild(s); + uiAppendStyle(s); } } function positionEditBadge() { - if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') { + syncEditBadgeHitProxies(); + return; + } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { if (editBadgeEl) editBadgeEl.style.display = 'none'; + syncEditBadgeHitProxies(); return; } editBadgeEl.style.display = 'flex'; @@ -4047,7 +4422,7 @@ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN; } // Re-render the bar so the Tune chip picks up the active styling. - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } function closeTunePopover() { @@ -4055,13 +4430,13 @@ hideParamsPanel(); if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT; if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') { - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } } - // --------------------------------------------------------------------------- + // // Variant cycling in DOM - // --------------------------------------------------------------------------- + // function isVariantShown(el) { if (!el) return false; @@ -4081,9 +4456,42 @@ } } - function showVariantInDOM(sessionId, num) { + function scheduleCyclingBarSync(sessionId, variantNum) { + requestAnimationFrame(() => { + if (state !== 'CYCLING') return; + if (currentSessionId !== sessionId) return; + if (visibleVariant !== variantNum) return; + showOrUpdateCyclingBar(); + syncCyclingControls(); + positionBar(); + }); + } + + function syncCyclingControls() { + const shown = svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0 + ? svelteComponentSession.mountedVariant + : visibleVariant; + const counter = uiGetById(PREFIX + '-variant-counter'); + if (counter && arrivedVariants > 0) counter.textContent = shown + '/' + arrivedVariants; + const prev = uiGetById(PREFIX + '-variant-prev'); + const next = uiGetById(PREFIX + '-variant-next'); + if (prev) prev.style.opacity = shown <= 1 ? '0.3' : '1'; + if (next) next.style.opacity = shown >= arrivedVariants ? '0.3' : '1'; + if (currentSessionId && state === 'CYCLING') saveSession(); + } + + async function showVariantInDOM(sessionId, num) { + if (svelteComponentSession?.sessionId === sessionId) { + visibleVariant = num; + const mounted = await mountSvelteComponentVariant(num); + if (!mounted) return false; + updateSelectedElement(); + refreshParamsPanel(); + scheduleCyclingBarSync(sessionId, num); + return true; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); - if (!wrapper) return; + if (!wrapper) return false; for (const child of wrapper.children) { const v = child.dataset ? child.dataset.impeccableVariant : null; if (!v) continue; @@ -4093,6 +4501,378 @@ // CYCLING yet, the subsequent CYCLING transition triggers its own // refresh) and every cycle step. refreshParamsPanel(); + return true; + } + + function isSvelteComponentManifestPath(filePath) { + return String(filePath || '').endsWith('manifest.json'); + } + + function parseOriginalMarkupElement(originalMarkup) { + const parser = new DOMParser(); + const doc = parser.parseFromString('
' + originalMarkup + '
', 'text/html'); + return doc.getElementById('impeccable-anchor')?.firstElementChild || null; + } + + function findLiveElementForOriginalMarkup(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const tag = origContent.tagName.toLowerCase(); + const cls = origContent.className; + let liveEl = null; + if (origContent.id) { + liveEl = document.getElementById(origContent.id); + } else if (cls) { + const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + for (const c of candidates) { + if (c.className === cls && !own(c)) { liveEl = c; break; } + } + if (!liveEl) { + const expectedClasses = String(cls).split(/\s+/).filter(Boolean); + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } + } + } + return liveEl; + } + + function isSvelteInsertManifest(manifest) { + return manifest?.previewMode === 'svelte-component' && manifest?.mode === 'insert'; + } + + function findLiveElementForSvelteManifest(manifest) { + if (isSvelteInsertManifest(manifest)) { + const anchor = findInsertAnchorInDom(); + if (anchor?.parentElement) return anchor; + } + return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function loadSvelteRuntime(runtimeModule) { + const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js'; + const url = new URL(modulePath, location.origin).href; + if (!svelteRuntimePromise) { + svelteRuntimePromise = import(/* @vite-ignore */ url); + } + return svelteRuntimePromise; + } + + // Svelte component variants declare their params in a sidecar params.json under + // componentDir (keyed by variant number), because a `data-impeccable-params` + // attribute with JSON braces can't survive the Svelte compiler. Returns a map of + // { "1": [...params], "2": [...] }; an empty object when the agent declared none. + async function loadSvelteComponentParams(manifest) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir) return {}; + const paramsPath = dir + '/params.json'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(paramsPath); + try { + const res = await fetch(url); + if (!res.ok) return {}; + const parsed = JSON.parse(await res.text()); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out = {}; + for (const [key, value] of Object.entries(parsed)) { + if (Array.isArray(value)) out[String(key)] = value; + } + return out; + } catch { + return {}; + } + } + + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { + const contract = manifest?.propContract || []; + const values = {}; + if (!liveEl || contract.length === 0) return values; + const sourceOriginal = parseOriginalMarkupElement(manifest.originalMarkup || ''); + if (!sourceOriginal) return values; + const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl); + for (const entry of contract) { + const token = '{' + entry.expr + '}'; + values[entry.prop] = map.get(token) || ''; + } + return values; + } + + async function mountSvelteComponentVariant(variantNum) { + if (!svelteComponentSession || !variantNum) return false; + const { manifest, mountTargetEl, sessionId } = svelteComponentSession; + try { + const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement; + svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null; + const runtime = await loadSvelteRuntime(manifest.runtimeModule); + const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte'; + const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now(); + const mod = await import(/* @vite-ignore */ moduleUrl); + const Component = mod.default; + if (svelteComponentSession.mountedInstance && runtime.unmount) { + await runtime.unmount(svelteComponentSession.mountedInstance); + svelteComponentSession.mountedInstance = null; + } + svelteComponentSession.mountedInstance = runtime.mount(Component, { + target: mountTargetEl, + props: { ...svelteComponentSession.propValues }, + intro: false, + }); + svelteComponentSession.mountedVariant = variantNum; + svelteComponentSession.runtime = runtime; + if (state === 'CYCLING') syncCyclingControls(); + const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (nextAnchor) { + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(nextAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = nextAnchor; + } else { + requestAnimationFrame(() => { + if (svelteComponentSession?.sessionId !== sessionId) return; + const settledAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!settledAnchor) return; + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(settledAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = settledAnchor; + }); + } + return true; + } catch (err) { + if (svelteComponentSession?.sessionId === sessionId) { + svelteComponentSession.swapAnchor = null; + } + console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err); + return false; + } + } + + function teardownSvelteComponentSession(restoreOriginal) { + if (!svelteComponentSession) return; + const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + if (restoreOriginal && detachedOriginal && wrapperEl?.parentElement) { + wrapperEl.parentElement.replaceChild(detachedOriginal, wrapperEl); + } else if (wrapperEl?.parentElement) { + wrapperEl.remove(); + } + svelteComponentSession = null; + svelteRuntimePromise = null; + } + + function applyOriginalAttrsToSvelteAnchor(el, originalMarkup) { + if (!el || !originalMarkup) return; + const original = parseOriginalMarkupElement(originalMarkup); + if (!original || original.tagName !== el.tagName) return; + for (const attr of original.attributes) { + if (attr.name === 'class') { + for (const className of attr.value.split(/\s+/).filter(Boolean)) { + el.classList.add(className); + } + } else if (!el.hasAttribute(attr.name)) { + el.setAttribute(attr.name, attr.value); + } + } + } + + function commitAcceptedSvelteComponentToDom(sessionId) { + if (!svelteComponentSession || svelteComponentSession.sessionId !== sessionId) return false; + const { wrapperEl, runtime, mountedInstance, manifest } = svelteComponentSession; + const anchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!anchor || !wrapperEl?.parentElement) return false; + const committed = anchor.cloneNode(true); + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(committed, manifest.originalMarkup || ''); + } + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + wrapperEl.parentElement.replaceChild(committed, wrapperEl); + svelteComponentSession = null; + svelteRuntimePromise = null; + selectedElement = committed; + return true; + } + + async function injectSvelteComponentsFromManifest(manifestPath, sessionId) { + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(manifestPath); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + const manifest = JSON.parse(await res.text()); + if (manifest.id !== sessionId) return; + + const paramsByVariant = await loadSvelteComponentParams(manifest); + currentSessionId = sessionId; + expectedVariants = Number(manifest.count) || expectedVariants || 1; + rememberSessionFileMeta({ + sourceFile: manifest.sourceFile, + previewFile: manifestPath, + previewMode: 'svelte-component', + }); + if (state !== 'CYCLING') state = 'GENERATING'; + + const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { + recoveryWaitingForAnchor = false; + svelteComponentSession.paramsByVariant = paramsByVariant; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; + await mountSvelteComponentVariant(visibleVariant || 1); + state = 'CYCLING'; + showOrUpdateCyclingBar(); + saveSession(); + return; + } + + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) { + console.warn('[impeccable] Could not find original element in live DOM.'); + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants + ? visibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + saveSession(); + queueCheckpoint('svelte_component_anchor_missing'); + waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return; + } + + const wrapper = document.createElement('div'); + wrapper.dataset.impeccableVariants = sessionId; + wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1); + wrapper.dataset.impeccablePreview = 'svelte-component'; + wrapper.style.display = 'contents'; + + const mountTarget = document.createElement('div'); + mountTarget.dataset.impeccableComponentMount = sessionId; + mountTarget.style.display = 'contents'; + wrapper.appendChild(mountTarget); + + const insertMode = isSvelteInsertManifest(manifest); + const detachedOriginal = insertMode ? null : liveEl; + if (insertMode) { + removeInsertPlaceholderDom(); + if (manifest.position === 'before') liveEl.parentElement.insertBefore(wrapper, liveEl); + else liveEl.parentElement.insertBefore(wrapper, liveEl.nextSibling); + } else { + liveEl.parentElement.replaceChild(wrapper, liveEl); + } + + svelteComponentSession = { + sessionId, + manifest, + insertMode, + wrapperEl: wrapper, + mountTargetEl: mountTarget, + detachedOriginal, + mountedInstance: null, + mountedVariant: 0, + runtime: null, + propValues: buildSveltePropValuesFromLiveElement(detachedOriginal, manifest), + paramsByVariant, + }; + if (pendingSvelteComponentRetryObserver) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + } + recoveryWaitingForAnchor = false; + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + + const mounted = await mountSvelteComponentVariant(visibleVariant); + if (!mounted) { + // The compiled component threw (e.g. a Svelte compile error in the + // variant file). Don't strand the bar in an empty CYCLING state; restore + // the original element and reset to PICKING so the user can retry. + abortSvelteComponentInjection(sessionId, 'A variant failed to compile. Fix the component and re-run.'); + return; + } + + selectedElement = mountTarget.firstElementChild || mountTarget; + state = 'CYCLING'; + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.'); + } catch (err) { + console.error('[impeccable] Failed to mount Svelte component variants:', err); + abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.'); + } + } + + function waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }) { + if (pendingSvelteComponentRetryObserver) pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = new MutationObserver(() => { + if (svelteComponentSession?.sessionId === sessionId) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + return; + } + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) return; + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + injectSvelteComponentsFromManifest(manifestPath, sessionId); + }); + pendingSvelteComponentRetryObserver.observe(document.body, { childList: true, subtree: true }); + } + + // Reset cleanly when a Svelte component session can't mount: tear the wrapper + // down (restoring the original element), clear persisted session state, and + // return the bar to PICKING. Avoids the stuck 0/0 CYCLING bar. + function abortSvelteComponentInjection(sessionId, message) { + try { + if (svelteComponentSession?.sessionId === sessionId) { + teardownSvelteComponentSession(true); + } else { + const orphan = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (orphan) orphan.remove(); + } + } catch (err) { + console.warn('[impeccable] Svelte component abort cleanup failed:', err); + } + hideShaderOverlay(); + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + stopScrollLock(); + clearSession(); + clearHandled(); + resetSessionFileMeta(); + currentSessionId = null; + expectedVariants = 0; + arrivedVariants = 0; + visibleVariant = 0; + selectedElement = null; + state = 'PICKING'; + hideBar(); + if (message) showToast(message, 5000); } /** @@ -4101,6 +4881,11 @@ * This works even when the dev server caches HTML (Bun, static servers). */ function injectVariantsFromSource(filePath, sessionId) { + if (isSvelteComponentManifestPath(filePath)) { + injectSvelteComponentsFromManifest(filePath, sessionId); + return; + } + rememberSessionFileMeta({ file: filePath }); const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) @@ -4119,7 +4904,7 @@ const doc = parser.parseFromString(block, 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { - console.error('[impeccable] Variant wrapper not found in source file.'); + console.warn('[impeccable] Variant wrapper not found in source file.'); return; } @@ -4134,31 +4919,31 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; - if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - } - + const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); if (!liveEl) { - console.error('[impeccable] Could not find original element in live DOM.'); + console.warn('[impeccable] Could not find original element in live DOM.'); + selectedElement = document.body; + recoveryWaitingForAnchor = true; + state = 'GENERATING'; + showBar('generating'); + saveSession(); + showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } + recoveryWaitingForAnchor = false; // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); arrivedVariants = variants.length; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + recoverEmptyCycling('source-fallback-empty'); + return; + } const saved = loadSession(); const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants @@ -4170,8 +4955,9 @@ selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4184,21 +4970,129 @@ }); } - function cycleVariant(dir) { + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { + const map = new Map(); + if (!sourceOriginal || !liveOriginal) return map; + + const sourceNodes = collectTextNodes(sourceOriginal) + .filter((node) => /\{[^{}]+\}/.test(node.nodeValue || '')); + const liveTexts = collectTextNodes(liveOriginal) + .map((node) => normalizePreviewText(node.nodeValue || '')) + .filter(Boolean); + let liveIndex = 0; + + for (const sourceNode of sourceNodes) { + const sourceText = sourceNode.nodeValue || ''; + const tokens = sourceText.match(/\{[^{}]+\}/g) || []; + if (tokens.length === 0) continue; + + const liveText = liveTexts[liveIndex++] || ''; + if (!liveText) continue; + + if (tokens.length === 1) { + const token = tokens[0]; + const normalizedSource = normalizePreviewText(sourceText); + if (normalizedSource === token) { + map.set(token, liveText); + continue; + } + + const match = liveText.match(expressionTextMatcher(sourceText, [token])); + if (match && match[1]) map.set(token, match[1].trim()); + continue; + } + + if (normalizePreviewText(sourceText) === tokens.join(' ')) { + for (const token of tokens) { + const tokenLiveText = liveTexts[liveIndex - 1] || ''; + if (tokenLiveText) map.set(token, tokenLiveText); + } + } + } + + return map; + } + + function expressionTextMatcher(sourceText, tokens) { + let pattern = '^'; + let cursor = 0; + for (const token of tokens) { + const index = sourceText.indexOf(token, cursor); + if (index === -1) continue; + pattern += escapeRegExp(sourceText.slice(cursor, index)).replace(/\s+/g, '\\s*'); + pattern += '(.*?)'; + cursor = index + token.length; + } + pattern += escapeRegExp(sourceText.slice(cursor)).replace(/\s+/g, '\\s*') + '$'; + return new RegExp(pattern); + } + + function collectTextNodes(root) { + if (!root) return []; + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + nodes.push(node); + node = walker.nextNode(); + } + return nodes; + } + + function normalizePreviewText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + async function selectVariant(next, checkpointReason) { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - const next = visibleVariant + dir; + if (variantSelectionInFlight) return; if (next < 1 || next > arrivedVariants) return; - visibleVariant = next; - showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself - updateSelectedElement(); - updateBarContent('cycling'); - positionBar(); - saveSession(); - queueCheckpoint('variant_changed'); + if (next === visibleVariant) return; + + const previous = visibleVariant; + variantSelectionInFlight = true; + const selectionPromise = (async () => { + visibleVariant = next; + showOrUpdateCyclingBar(); + saveSession(); + const shown = await showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself + if (!shown) { + visibleVariant = previous; + await showVariantInDOM(currentSessionId, previous); + showOrUpdateCyclingBar(); + saveSession(); + return; + } + updateSelectedElement(); + showOrUpdateCyclingBar(); + positionBar(); + saveSession(); + if (checkpointReason) queueCheckpoint(checkpointReason); + })(); + variantSelectionPromise = selectionPromise; + try { + await selectionPromise; + } finally { + if (variantSelectionPromise === selectionPromise) variantSelectionPromise = null; + variantSelectionInFlight = false; + } + } + + function cycleVariant(dir) { + selectVariant(visibleVariant + dir, 'variant_changed'); } function updateSelectedElement() { if (!currentSessionId) return; + if (svelteComponentSession?.sessionId === currentSessionId) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; + return; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); @@ -4206,6 +5100,9 @@ } function readVisibleVariantFromDOM(sessionId) { + if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { + return svelteComponentSession.mountedVariant; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -4341,9 +5238,9 @@ // scrollY that the next resume needs to read. } - // --------------------------------------------------------------------------- + // // MutationObserver for progressive variant reveal - // --------------------------------------------------------------------------- + // function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -4426,10 +5323,11 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); updateSelectedElement(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4445,9 +5343,9 @@ return obs; } - // --------------------------------------------------------------------------- + // // Bar scroll tracking - // --------------------------------------------------------------------------- + // function startScrollTracking() { function tick() { @@ -4483,10 +5381,10 @@ if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; } } - // --------------------------------------------------------------------------- + // // SSE (server→browser) + fetch POST (browser→server) // Zero-dependency replacement for WebSocket. - // --------------------------------------------------------------------------- + // let evtSource = null; let sseRetries = 0; @@ -4509,6 +5407,7 @@ console.log('[impeccable] Live mode connected.'); syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); + restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; syncPageChatFocus('sse-connected'); break; @@ -4531,11 +5430,12 @@ break; case 'done': if (maybeCompleteSteer(msg)) break; + rememberSessionFileMeta(msg); // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); } @@ -4557,7 +5457,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; if (state !== 'GENERATING') return; showToast( - "Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.", + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", 15000, ); }, 2000); @@ -4571,12 +5471,18 @@ // the final complete event. Keep the browser in its recoverable // saving state while the source cleanup is still in flight. break; + case 'discarded': + if (msg.id && msg.id === currentSessionId) { + markSessionHandled(); + cleanup(); + } + break; case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; state = 'CYCLING'; updateBarContent('cycling'); - showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000); + showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; } if (maybeCompleteSteer(msg)) break; @@ -4656,6 +5562,9 @@ expectedVariants, arrivedVariants, visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, paramValues: { ...paramsCurrentValues }, }; } @@ -4665,6 +5574,20 @@ return sendEvent(checkpointPayload(reason)).catch(() => null); } + function sendSteerCheckpoint(id, reason, extra) { + if (!id) return Promise.resolve(null); + return sendEvent({ + type: 'checkpoint', + id, + revision: sessionState.nextCheckpointRevision(), + owner: browserOwner, + phase: 'steer', + reason, + pageUrl: location.pathname, + ...(extra || {}), + }).catch(() => null); + } + function queueCheckpoint(reason) { if (!currentSessionId) return; if (checkpointTimer) clearTimeout(checkpointTimer); @@ -4674,9 +5597,9 @@ }, 120); } - // --------------------------------------------------------------------------- + // // Event handlers - // --------------------------------------------------------------------------- + // function handleMouseMove(e) { if (pendingApplyInFlight) return; @@ -4860,7 +5783,7 @@ // // DISABLED: quick-Go workflows pay an extra harness round trip because // prefetch + generate arrive as two events instead of one. Re-enable with - // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to + // a browser-side debounce (~800-1000ms, cancelled on Go) if we want to // resurrect this. Server validator and skill dispatch remain in place so // flipping this flag is the only change needed. const PREFETCH_ENABLED = false; @@ -4876,6 +5799,14 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + const deepActive = activeElementDeep(); + if ( + deepActive + && own(deepActive) + && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + ) { + return; + } // While a contenteditable text-leaf is focused, let the browser handle // all keys except Escape. Escape cancels the current edit (restores // original text) and blurs without saving, staying in CONFIGURING. @@ -4982,7 +5913,7 @@ if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!selectedElement || state !== 'CONFIGURING') return; stopVoice({ suppressSubmit: true }); - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; // Commit any pending pin edit BEFORE we snapshot annotations. @@ -4996,6 +5927,7 @@ expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); // Flip to GENERATING immediately so the bar morphs without waiting on // capture + upload. The event is emitted from captureAndEmit() once the @@ -5054,7 +5986,7 @@ function handleInsertCreate() { if (!placeholderElement || !insertAnchorElement || state !== 'CONFIGURING' || configureKind !== 'insert') return; - const input = document.getElementById(PREFIX + '-insert-input'); + const input = uiGetById(PREFIX + '-insert-input'); const prompt = input ? input.value.trim() : ''; if (annotEditing) finalizeEditingPin(); const snapshot = { @@ -5064,10 +5996,12 @@ if (!canCreateInsert({ prompt, comments: snapshot.comments, strokes: snapshot.strokes })) return; stopVoice({ suppressSubmit: true }); + pendingAcceptedSession = null; currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); selectedElement = placeholderElement; insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement); @@ -5107,9 +6041,9 @@ captureAndEmit(elForCapture, basePayload, snapshot, captureRect); } - // --------------------------------------------------------------------------- + // // Screenshot capture + upload - // --------------------------------------------------------------------------- + // let msLoadPromise = null; function loadModernScreenshot() { @@ -5120,7 +6054,7 @@ s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; s.onload = () => resolve(window.modernScreenshot); s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; - document.head.appendChild(s); + uiAppendStyle(s); }); return msLoadPromise; } @@ -5235,11 +6169,113 @@ return '#ffffff'; } + function captureChromeNodes() { + const nodes = []; + const add = (node) => { + if (!node || node === document.body || nodes.includes(node)) return; + nodes.push(node); + }; + add(document.getElementById(PREFIX + '-root')); + [ + PREFIX + '-highlight', + PREFIX + '-tooltip', + PREFIX + '-bar', + PREFIX + '-picker', + PREFIX + '-params-panel', + PREFIX + '-insert-line', + PREFIX + '-insert-placeholder', + PREFIX + '-insert-create-tooltip', + PREFIX + '-annot', + PREFIX + '-design-host', + PREFIX + '-toast', + PREFIX + '-shader', + ].forEach((id) => add(uiGetById(id))); + return nodes; + } + + async function hideCaptureChromeForShaderProxy(fn) { + const saved = captureChromeNodes().map((node) => ({ + node, + visibility: node.style.visibility, + priority: node.style.getPropertyPriority('visibility'), + })); + for (const { node } of saved) { + node.style.setProperty('visibility', 'hidden', 'important'); + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + try { + return await fn(); + } finally { + for (const { node, visibility, priority } of saved) { + node.style.setProperty('visibility', visibility, priority); + } + } + } + + function shouldUseAncestorCropShaderProxy(el) { + // TODO: Enable this proxy for React/Vue/etc. adapters once their live + // preview mounts are covered by the same shader regression checks. + const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase(); + if (adapter === 'svelte' || adapter === 'sveltekit') return true; + if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true; + const wrapper = el?.closest?.('[data-impeccable-variants]'); + return wrapper?.dataset?.impeccablePreview === 'svelte-component'; + } + + function paintsShaderProxySurface(node) { + const s = getComputedStyle(node); + return !isTransparentColor(s.backgroundColor) + || (s.backgroundImage && s.backgroundImage !== 'none') + || paintsBackdrop(node); + } + + function findShaderProxyCaptureRoot(el) { + const doc = el.ownerDocument || document; + const er = el.getBoundingClientRect(); + let node = el.parentElement; + while (node && node !== doc.documentElement) { + const nr = node.getBoundingClientRect(); + const containsElement = + nr.width > 0 && nr.height > 0 && + nr.left <= er.left + 0.5 && + nr.top <= er.top + 0.5 && + nr.right >= er.right - 0.5 && + nr.bottom >= er.bottom - 0.5; + if (containsElement && paintsShaderProxySurface(node)) return node; + node = node.parentElement; + } + return null; + } + // Capture the element (with current annotations baked in) and return // { blob, paper }: the PNG Blob, plus the representative backdrop tone for the // shader's halftone ground (so capture, upload, and shader all agree on what // sits behind the element). Shared between the Go flow (uploads the blob) and // the shader-resume path. + async function captureElementFromRenderedAncestor(ms, el, opts) { + const doc = el.ownerDocument || document; + const captureRoot = findShaderProxyCaptureRoot(el); + if (!captureRoot) throw new Error('No painted ancestor for Svelte shader proxy'); + const rootCanvas = await ms.domToCanvas(captureRoot, opts); + const S = opts.scale; + const er = el.getBoundingClientRect(); + const rr = captureRoot.getBoundingClientRect(); + const sx = (er.left - rr.left) * S; + const sy = (er.top - rr.top) * S; + const sw = er.width * S; + const sh = er.height * S; + if (sw <= 0 || sh <= 0) throw new Error('Selected element has no visible capture rect'); + const crop = doc.createElement('canvas'); + crop.width = Math.max(1, Math.round(sw)); + crop.height = Math.max(1, Math.round(sh)); + const cctx = crop.getContext('2d', { willReadFrequently: true }); + cctx.drawImage(rootCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height); + const paper = dominantRgb01(cctx, crop.width, crop.height) || averageRgb01(cctx, crop.width, crop.height); + const blob = await new Promise((res) => crop.toBlob(res, 'image/png')); + if (!blob) throw new Error('Ancestor crop failed to produce a PNG blob'); + return { blob, paper }; + } + async function captureElementToBlob(el, snapshot, rect) { try { if (document.fonts?.ready) await document.fonts.ready; } catch {} const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); @@ -5261,6 +6297,13 @@ scale: Math.min(window.devicePixelRatio || 1, 2), font: fontCssText ? { cssText: fontCssText } : undefined, }; + if (shouldUseAncestorCropShaderProxy(el)) { + try { + return await hideCaptureChromeForShaderProxy(() => captureElementFromRenderedAncestor(ms, el, opts)); + } catch (err) { + console.warn('[impeccable] Svelte ancestor crop capture failed, falling back to element capture:', err); + } + } const bg = resolveCanvasBackground(el); // Fast path: the element paints its own background, or an opaque ancestor // color was found. modern-screenshot bakes that color; paper matches it. @@ -5342,13 +6385,13 @@ sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); } - // --------------------------------------------------------------------------- + // // Shader overlay - renders the captured screenshot as a WebGL texture and // runs an editorial "ink-wash" fragment shader over it during generation. // A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku // and leaving a soft trail. Makes the wait feel like a letterpress scan // instead of a dead spinner. - // --------------------------------------------------------------------------- + // const SHADER_VS = `attribute vec2 a_position; attribute vec2 a_uv; @@ -5502,6 +6545,31 @@ void main() { return n ? [r / n / 255, g / n / 255, b / n / 255] : SHADER_PAPER_FALLBACK; } + // Pick the most common visible color cluster from a crop. A straight average + // gets pulled by text and icons; the dominant bucket usually represents the + // surface the shader should dissolve into. + function dominantRgb01(ctx, w, h) { + const data = ctx.getImageData(0, 0, w, h).data; + const stride = Math.max(1, Math.floor((w * h) / 6000)); + const buckets = new Map(); + for (let p = 0; p < w * h; p += stride) { + const i = p * 4; + if (data[i + 3] < 16) continue; + const key = (data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4); + const bucket = buckets.get(key) || { count: 0, r: 0, g: 0, b: 0 }; + bucket.count += 1; + bucket.r += data[i]; + bucket.g += data[i + 1]; + bucket.b += data[i + 2]; + buckets.set(key, bucket); + } + let best = null; + for (const bucket of buckets.values()) { + if (!best || bucket.count > best.count) best = bucket; + } + return best ? [best.r / best.count / 255, best.g / best.count / 255, best.b / best.count / 255] : null; + } + // Average the backdrop sampled just OUTSIDE an element's rect within a larger // canvas. The ground tone for the dissolve must be the real backdrop, not the // mean of the element's own crop - averaging the crop folds in the element's @@ -5572,7 +6640,7 @@ void main() { fallback.style.backgroundRepeat = 'no-repeat'; fallback.style.outline = '2px dashed ' + C.brand; fallback.style.outlineOffset = '-2px'; - document.body.appendChild(fallback); + uiAppend(fallback); shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl }; } @@ -5582,16 +6650,19 @@ void main() { const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); + const radius = getComputedStyle(el).borderRadius; canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); Object.assign(canvas.style, { position: 'fixed', top: rect.top + 'px', left: rect.left + 'px', width: rect.width + 'px', height: rect.height + 'px', + borderRadius: radius, + overflow: 'hidden', pointerEvents: 'none', zIndex: Z.bar - 1, }); - document.body.appendChild(canvas); + uiAppend(canvas); const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) || canvas.getContext('experimental-webgl'); @@ -5685,8 +6756,12 @@ void main() { frame(); } - function handleAccept() { + async function handleAccept() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } + if (pendingAcceptedSession || state === 'SAVING') return; + if (variantSelectionPromise) { + try { await variantSelectionPromise; } catch { /* failed selection falls back below */ } + } if (!currentSessionId || arrivedVariants === 0) return; const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId); if (domVisibleVariant > 0) visibleVariant = domVisibleVariant; @@ -5696,30 +6771,39 @@ void main() { variantId: String(visibleVariant), pageUrl: location.pathname, }; + const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } + // The accepted variant is already the only visible child of the wrapper + // (all other variants are display:none). HMR from the source rewrite will + // replace the wrapper imminently. Don't eagerly replaceChild here - React + // reconciliation races with our mutation and throws NotFoundError in Next + // 16 / Turbopack. Schedule a fallback that runs the manual swap only if + // HMR hasn't cleaned up by then (keeps static-server flows working). const acceptedSessionId = currentSessionId; const acceptedVariant = visibleVariant; + const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId + || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - pendingAcceptedSession = { - id: acceptedSessionId, - variant: String(acceptedVariant), - ...acceptedSnapshot, - finalizing: false, - }; state = 'SAVING'; updateBarContent('saving'); + pendingAcceptedSession = { + id: acceptedSessionId, + variant: String(acceptedVariant), + isSvelteComponent: acceptedIsSvelteComponent, + ...acceptedSnapshot, + finalizing: false, + }; + saveSession(); sendEvent(acceptPayload, { throwOnError: true }) - .then(() => { - markSessionHandled(); - }) + .then(() => {}) .catch(() => { - pendingAcceptedSession = null; + if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); } @@ -5733,19 +6817,21 @@ void main() { } if (pending.finalizing) return true; pending.finalizing = true; - + markSessionHandled(); + if (pending.isSvelteComponent) { + commitAcceptedSvelteComponentToDom(pending.id); + } state = 'CONFIRMED'; updateBarContent('confirmed'); + scheduleAcceptCleanup(pending); + return true; + } - // Give framework HMR a short chance to render the now-clean accepted - // source. If it misses the update, unwrap the accepted variant after the - // source-side completion event so the page is not left empty or stale. + function scheduleAcceptCleanup(accepted) { setTimeout(function() { - ensureAcceptedDomClean(pending); + if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted); cleanupAcceptedSession(); }, 1200); - - return true; } function snapshotAcceptedVariantDom(sessionId, variantId) { @@ -5833,6 +6919,7 @@ void main() { stopScrollLock(); clearScrollY(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5841,6 +6928,28 @@ void main() { state = 'PICKING'; } + function commitAcceptedVariantToDom(sessionId, variantId) { + const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapper) return false; + const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); + if (!accepted || !accepted.firstElementChild) return false; + const parent = wrapper.parentElement; + if (!parent) return false; + + const style = wrapper.querySelector('style[data-impeccable-css]'); + if (style && !document.querySelector('style[data-impeccable-accepted-css="' + sessionId + '"]')) { + const promotedStyle = style.cloneNode(true); + promotedStyle.setAttribute('data-impeccable-accepted-css', sessionId); + parent.insertBefore(promotedStyle, wrapper); + } + + const committed = accepted.cloneNode(true); + committed.removeAttribute('hidden'); + committed.style.display = 'contents'; + parent.replaceChild(committed, wrapper); + return true; + } + function handleDiscard() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!currentSessionId) return; @@ -5852,11 +6961,141 @@ void main() { .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000)); } - // --------------------------------------------------------------------------- + // // Session persistence via live-browser-session.js - // --------------------------------------------------------------------------- + // // Survives page reloads, browser close/reopen, HMR, and accidental refreshes. + function normalizeSessionPath(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed.replace(/\\/g, '/') : null; + } + + function resetSessionFileMeta() { + currentSourceFile = null; + currentPreviewFile = null; + currentPreviewMode = null; + recoveryWaitingForAnchor = false; + } + + function rememberSessionFileMeta(meta = {}) { + const file = normalizeSessionPath(meta.file); + const sourceFile = normalizeSessionPath(meta.sourceFile); + const previewFile = normalizeSessionPath(meta.previewFile); + const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null); + + if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) { + currentPreviewMode = 'svelte-component'; + currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile); + currentSourceFile = sourceFile || currentSourceFile; + return; + } + + if (sourceFile || file) currentSourceFile = sourceFile || file; + if (previewFile) currentPreviewFile = previewFile; + if (previewMode) currentPreviewMode = previewMode; + } + + function applySavedSessionMeta(saved) { + if (!saved) return; + rememberSessionFileMeta(saved); + if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.action) selectedAction = saved.action; + if (saved.count) selectedCount = saved.count; + if (saved.previewMode) currentPreviewMode = saved.previewMode; + if (saved.paramValues && typeof saved.paramValues === 'object') { + paramsCurrentValues = { ...saved.paramValues }; + } + } + + function normalizePagePath(value) { + if (!value || typeof value !== 'string') return null; + try { + return new URL(value, location.origin).pathname; + } catch { + return value.split(/[?#]/)[0] || null; + } + } + + function pageMatchesCurrent(value) { + const path = normalizePagePath(value); + return !path || path === location.pathname; + } + + function isTerminalSessionSummary(session) { + return /^(completed|discarded|discard_requested|accept_requested)$/.test(String(session?.phase || '')); + } + + function findActiveSessionSummary(saved, activeSessions) { + if (!saved?.id || !Array.isArray(activeSessions)) return null; + return activeSessions.find((session) => + session?.id === saved.id + && pageMatchesCurrent(session.pageUrl || saved.pageUrl) + && !isTerminalSessionSummary(session) + ) || null; + } + + function clampVariantIndex(value, count) { + const num = Number(value); + const max = Number(count); + if (!Number.isFinite(num) || num < 1) return 0; + if (Number.isFinite(max) && max > 0 && num > max) return 0; + return Math.floor(num); + } + + function restoreSessionWithoutWrapper(reason, activeSessions) { + const saved = loadSession(); + if (!saved?.id || isSessionHandled(saved.id)) return false; + const savedState = String(saved.state || '').toUpperCase(); + if (savedState !== 'GENERATING' && savedState !== 'CYCLING') return false; + + const serverSession = findActiveSessionSummary(saved, activeSessions); + if (Array.isArray(activeSessions) && activeSessions.length > 0 && !serverSession) { + return false; + } + + currentSessionId = saved.id; + applySavedSessionMeta(serverSession); + applySavedSessionMeta(saved); + + expectedVariants = Number(saved.expected || serverSession?.expectedVariants || selectedCount || 0); + arrivedVariants = Number(saved.arrived || serverSession?.arrivedVariants || 0); + if (arrivedVariants <= 0 && currentPreviewFile) arrivedVariants = Number(serverSession?.expectedVariants || saved.expected || selectedCount || 0); + if (expectedVariants <= 0) expectedVariants = Number(serverSession?.expectedVariants || arrivedVariants || selectedCount || 0); + visibleVariant = clampVariantIndex(saved.visible, arrivedVariants || expectedVariants) + || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) + || (arrivedVariants > 0 ? 1 : 0); + + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + if (variantObserver) variantObserver.disconnect(); + variantObserver = startVariantObserver(currentSessionId); + saveSession(); + queueCheckpoint(reason || 'browser_restore_without_wrapper'); + + const restoreFile = currentPreviewMode === 'svelte-component' + ? currentPreviewFile + : (currentSourceFile || currentPreviewFile); + if (restoreFile) { + injectVariantsFromSource(restoreFile, currentSessionId); + return true; + } + + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return true; + } + + function restoreFromActiveSessions(activeSessions, reason) { + const wrapper = document.querySelector('[data-impeccable-variants]'); + if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false; + if (svelteComponentSession?.sessionId === currentSessionId) return false; + return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); + } + function saveSession() { if (!currentSessionId) return; // NOTE: scrollY is stored under a separate key (writeScrollY). Storing @@ -5869,6 +7108,11 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, + pageUrl: location.pathname, + paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, }); } @@ -5898,31 +7142,33 @@ void main() { } function cleanup() { - // Hide the wrapper immediately so variants disappear. DON'T structurally - // mutate the DOM yet - HMR from the agent's source rewrite is on its way, - // and a manual replaceChild under React causes NotFoundError when the - // reconciler later tries to remove a wrapper we already removed. - // Schedule a 2s fallback that does the manual swap only if HMR hasn't - // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const cleanupSessionId = currentSessionId; - if (cleanupSessionId) { + if (svelteComponentSession?.sessionId === cleanupSessionId) { + teardownSvelteComponentSession(true); + } else if (cleanupSessionId) { + // Hide the wrapper immediately so variants disappear. DON'T structurally + // mutate the DOM yet - HMR from the agent's source rewrite is on its way, + // and a manual replaceChild under React causes NotFoundError when the + // reconciler later tries to remove a wrapper we already removed. + // Schedule a 2s fallback that does the manual swap only if HMR hasn't + // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); if (wrapper) wrapper.style.display = 'none'; - } - setTimeout(function() { - if (!cleanupSessionId) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!wrapper) return; - const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - wrapper.parentElement.replaceChild(content, wrapper); - return; + setTimeout(function() { + if (!cleanupSessionId) return; + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) return; + const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); + if (orig) { + const content = orig.firstElementChild; + if (content) { + lateWrapper.parentElement.replaceChild(content, lateWrapper); + return; + } } - } - wrapper.remove(); - }, 2000); + lateWrapper.remove(); + }, 2000); + } hideBar(); hideHighlight(); stopScrollTracking(); @@ -5931,6 +7177,7 @@ void main() { clearScrollY(); finalizeInsertSession(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5938,9 +7185,9 @@ void main() { state = 'PICKING'; } - // --------------------------------------------------------------------------- + // // Toast - // --------------------------------------------------------------------------- + // function showToast(message, duration) { if (toastEl) toastEl.remove(); @@ -5964,7 +7211,7 @@ void main() { }); toastEl.id = PREFIX + '-toast'; toastEl.textContent = message; - document.body.appendChild(toastEl); + uiAppend(toastEl); requestAnimationFrame(() => { toastEl.style.opacity = '1'; toastEl.style.transform = 'translateX(-50%) translateY(0)'; @@ -5978,22 +7225,70 @@ void main() { }, duration); } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. function resumeSession() { const wrapper = document.querySelector('[data-impeccable-variants]'); - if (!wrapper) { clearSession(); clearHandled(); return false; } + if (!wrapper) { + if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } const sessionId = wrapper.dataset.impeccableVariants; // Don't resume if this session was already accepted/discarded if (isSessionHandled(sessionId)) return false; + // Svelte component sessions can't be resumed by counting DOM children: the + // wrapper holds a single mount target, not [data-impeccable-variant] nodes, + // and a page reload unmounts every compiled variant. Counting children here + // would strand the bar in CYCLING at 0/0. If there's no live in-memory mount + // for this wrapper, it's an orphan (reload / failed mount): drop it and let + // the live-server's SSE re-inject the manifest if the session is still live. + if (wrapper.dataset.impeccablePreview === 'svelte-component' + && svelteComponentSession?.sessionId !== sessionId) { + wrapper.remove(); + if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } + + if (wrapper.dataset.impeccablePreview === 'svelte-component') { + if (!svelteComponentSession?.mountedVariant) { + return true; + } + currentSessionId = sessionId; + expectedVariants = Number(wrapper.dataset.impeccableVariantCount) + || Number(svelteComponentSession.manifest?.count) + || expectedVariants + || 1; + arrivedVariants = expectedVariants; + const saved = loadSession(); + applySavedSessionMeta(saved); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = svelteComponentSession.mountedVariant > 0 && svelteComponentSession.mountedVariant <= arrivedVariants + ? svelteComponentSession.mountedVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = resolveSvelteComponentAnchor() + || wrapper.parentElement; + state = 'CYCLING'; + hideShaderOverlay(); + showBar('cycling'); + startScrollTracking(); + refreshParamsPanel(); + saveSession(); + queueCheckpoint('browser_resumed_svelte_component'); + return true; + } + currentSessionId = sessionId; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -6002,6 +7297,7 @@ void main() { // Restore state from localStorage if available const saved = loadSession(); if (saved && saved.id === sessionId) { + applySavedSessionMeta(saved); visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0); if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; @@ -6072,9 +7368,9 @@ void main() { return true; } - // --------------------------------------------------------------------------- + // // Global bar (always visible at bottom) - // --------------------------------------------------------------------------- + // let globalBarEl = null; let globalBarBrandEl = null; @@ -6166,6 +7462,8 @@ void main() { let pageChatExpanded = false; let steerLocked = false; let steerRequestId = null; + let steerPendingMessage = ''; + let steerInputWasFocused = false; let pageChatDotsEl = null; let steerAwaitTimer = null; let voiceRecognition = null; @@ -6179,7 +7477,7 @@ void main() { const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; - const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect'; + const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect'; const GLOBAL_BAR_SECTION_GAP = 8; const GLOBAL_BAR_INNER_GAP = 2; const GLOBAL_BAR_INNER_PAD_LEFT = 2; @@ -6323,7 +7621,7 @@ void main() { const attempt = () => { steerFocusRecoverTimer = null; if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (pageHasHostTextSelection()) { steerFocusRecoverTimer = setTimeout(attempt, 120); return; @@ -6344,7 +7642,7 @@ void main() { steerFocusSuspended = true; steerFocusPauseUntil = performance.now() + STEER_PAGE_FOCUS_PAUSE_MS; pagePointerGesture = { x: e.clientX, y: e.clientY, dragged: false }; - if (pageChatInput && document.activeElement === pageChatInput) { + if (pageChatInput && activeElementDeep() === pageChatInput) { pageChatInput.blur(); } } @@ -6404,7 +7702,7 @@ void main() { pickActive, pageChatReady: !!pageChatInput, pageChatExpanded, - active: steerFocusTargetLabel(document.activeElement), + active: steerFocusTargetLabel(activeElementDeep()), shouldSteer: shouldFocusSteerChat(), ...(extra || {}), }); @@ -6423,26 +7721,26 @@ void main() { function focusConfigureInput(reason) { steerFocusLog('focusConfigureInput', { reason }); const inputId = configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input'; - const input = document.getElementById(inputId); + const input = uiGetById(inputId); if (!input) { steerFocusLog('focusConfigureInput missing', { reason }); return; } setTimeout(() => { - const before = document.activeElement; + const before = activeElementDeep(); input.focus(); steerFocusLog('focusConfigureInput result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== input, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== input, }); }, 60); } function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; - const focused = document.activeElement === pageChatInput; + const focused = activeElementDeep() === pageChatInput; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; const P = pageChatPalette(); pageChatEl.style.borderColor = steerLocked @@ -6476,15 +7774,15 @@ void main() { } syncPageChatVisual(); pageChatInput.style.pointerEvents = 'auto'; - const before = document.activeElement; + const before = activeElementDeep(); try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== pageChatInput, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== pageChatInput, }); } @@ -6515,6 +7813,37 @@ void main() { return wrap; } + function keepSteerPointerInside(e, opts = {}) { + e.stopPropagation(); + if (opts.preventDefault !== false) e.preventDefault(); + } + + function preparePageChatInputForTyping() { + if (!pageChatEl || !pageChatInput) return false; + pageChatExpanded = true; + pageChatEl.dataset.expanded = 'true'; + pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; + pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return true; + } + + function focusPageChatInput(reason) { + if (!preparePageChatInputForTyping() || steerLocked) return false; + try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } + const focused = activeElementDeep() === pageChatInput; + if (focused) steerInputWasFocused = true; + syncPageChatFocusRing(); + return focused; + } + function clearSteerAwaitTimer() { if (steerAwaitTimer) { clearTimeout(steerAwaitTimer); @@ -6528,6 +7857,7 @@ void main() { if (!steerLocked || steerRequestId !== id) return; unlockSteerChat({ error: 'Steer timed out waiting for the agent. Check that live-poll is running and replies with steer_done.', + restoreMessage: steerPendingMessage, }); }, STEER_AWAIT_TIMEOUT_MS); } @@ -6538,19 +7868,12 @@ void main() { steerLocked = true; pageChatEl.dataset.processing = 'true'; pageChatInput.disabled = true; - pageChatInput.value = ''; - pageChatInput.blur(); + preparePageChatInputForTyping(); if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = true; pageChatVoiceBtn.style.display = 'none'; } - pageChatExpanded = false; - pageChatEl.dataset.expanded = 'false'; - pageChatEl.style.width = PAGE_CHAT_PROCESSING_W; pageChatEl.style.cursor = 'default'; - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; pageChatInput.style.pointerEvents = 'none'; if (pageChatHint) { pageChatHint.style.display = 'none'; @@ -6568,17 +7891,26 @@ void main() { function unlockSteerChat(opts) { clearSteerAwaitTimer(); + const restoreMessage = typeof opts?.restoreMessage === 'string' ? opts.restoreMessage : ''; + const keepExpanded = Boolean(opts?.error && restoreMessage); steerLocked = false; + const completedId = steerRequestId; steerRequestId = null; if (!pageChatEl) return; pageChatEl.dataset.processing = 'false'; pageChatEl.removeAttribute('aria-busy'); pageChatEl.setAttribute('aria-label', 'Steer the page'); - pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W; + pageChatExpanded = keepExpanded; + pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false'; + pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W; pageChatEl.style.cursor = 'pointer'; if (pageChatInput) { pageChatInput.disabled = false; - pageChatInput.value = ''; + pageChatInput.value = keepExpanded ? restoreMessage : ''; + pageChatInput.style.width = keepExpanded ? '' : '0'; + pageChatInput.style.padding = keepExpanded ? '0 6px' : '0'; + pageChatInput.style.opacity = keepExpanded ? '1' : '0'; + pageChatInput.style.pointerEvents = 'auto'; } if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = false; @@ -6586,18 +7918,28 @@ void main() { } if (pageChatHint) { pageChatHint.textContent = 'Steer'; - pageChatHint.style.display = ''; - pageChatHint.style.visibility = ''; + pageChatHint.style.display = keepExpanded ? 'none' : ''; + pageChatHint.style.visibility = keepExpanded ? 'hidden' : ''; + pageChatHint.style.opacity = keepExpanded ? '0' : '1'; } if (pageChatDotsEl?.parentNode) { pageChatDotsEl.remove(); pageChatDotsEl = null; } + steerPendingMessage = keepExpanded ? restoreMessage : ''; + steerInputWasFocused = false; syncPageChatChrome(); syncPageChatFocusRing(); if (opts?.error) showToast(String(opts.error), 5000); else if (opts?.message) showToast(String(opts.message), 4000); - syncPageChatFocus('steer-unlock'); + if (completedId) { + sendSteerCheckpoint(completedId, opts?.error ? 'steer_error' : 'steer_done', { + message: opts?.message || opts?.error || '', + file: opts?.file || '', + }); + } + if (keepExpanded) focusPageChatInput('steer-error-restore'); + else syncPageChatFocus('steer-unlock'); } function steerSpeechRecognitionCtor() { @@ -6651,7 +7993,7 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = document.getElementById(PREFIX + '-configure-voice'); + const voiceBtn = uiGetById(PREFIX + '-configure-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -6784,7 +8126,7 @@ void main() { } function configureVoiceContext() { - const input = document.getElementById( + const input = uiGetById( configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input', ); return { @@ -6819,26 +8161,37 @@ void main() { if (!text || steerLocked) return; const id = id8(); steerRequestId = id; + steerPendingMessage = text; + if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); lockSteerChat(); scheduleSteerAwaitTimeout(id); + sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); sendEvent({ type: 'steer', id, message: text, pageUrl: location.href, }).then((res) => { - if (!res) unlockSteerChat({ error: 'Could not reach live server' }); + if (!res) { + sendSteerCheckpoint(id, 'steer_send_failed', { message: text }); + unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text }); + } }); } function maybeCompleteSteer(msg) { if (!steerRequestId || msg.id !== steerRequestId) return false; if (msg.type === 'steer_done') { - unlockSteerChat({ message: msg.message }); + unlockSteerChat({ message: msg.message, file: msg.file }); + if (msg.file && /\.svelte(?:$|\?)/.test(String(msg.file))) { + setTimeout(() => { + if (!steerLocked) showToast('Steer applied. Reload if the page has not refreshed yet.', 5000); + }, 4500); + } return true; } if (msg.type === 'error') { - unlockSteerChat({ error: msg.message || 'Steer failed' }); + unlockSteerChat({ error: msg.message || 'Steer failed', restoreMessage: steerPendingMessage }); return true; } return false; @@ -6847,21 +8200,10 @@ void main() { function expandPageChat(opts) { const focus = !opts || opts.focus !== false; if (!pageChatEl || !pageChatInput || steerLocked) return; - pageChatExpanded = true; - pageChatEl.dataset.expanded = 'true'; - pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; - pageChatEl.style.cursor = 'text'; - if (pageChatHint) { - pageChatHint.style.display = 'none'; - pageChatHint.style.opacity = '0'; - } - pageChatInput.style.width = ''; - pageChatInput.style.padding = '0 6px'; - pageChatInput.style.opacity = '1'; - pageChatInput.style.pointerEvents = 'auto'; + preparePageChatInputForTyping(); syncPageChatChrome(); syncPageChatFocusRing(); - if (focus) pageChatInput.focus(); + if (focus) focusPageChatInput('expand-page-chat'); } function collapsePageChat(opts) { @@ -6878,7 +8220,7 @@ void main() { } else { pageChatInput.style.pointerEvents = 'auto'; } - if (pageChatHint && document.activeElement !== pageChatInput) { + if (pageChatHint && activeElementDeep() !== pageChatInput) { pageChatHint.style.display = ''; pageChatHint.style.opacity = '1'; } @@ -6952,7 +8294,7 @@ void main() { pageChatEl.appendChild(pageChatInput); pageChatEl.appendChild(pageChatVoiceBtn); - if (!document.getElementById(PREFIX + '-page-chat-style')) { + if (!uiGetById(PREFIX + '-page-chat-style')) { const s = document.createElement('style'); s.id = PREFIX + '-page-chat-style'; s.textContent = @@ -6966,23 +8308,34 @@ void main() { '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } - pageChatEl.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { + keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat(); + expandPageChat({ focus: false }); + focusPageChatInput('page-chat-click'); }); - pageChatVoiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); + pageChatVoiceBtn.addEventListener('mousedown', keepSteerPointerInside); pageChatVoiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); + keepSteerPointerInside(e); if (steerLocked) return; toggleSteerVoice(); }); + pageChatInput.addEventListener('pointerdown', keepSteerPointerInside); + pageChatInput.addEventListener('mousedown', keepSteerPointerInside); + pageChatInput.addEventListener('click', (e) => { + keepSteerPointerInside(e); + if (!steerLocked) focusPageChatInput('page-chat-input-click'); + }); + pageChatInput.addEventListener('input', () => { syncPageChatVisual(); }); @@ -6995,7 +8348,7 @@ void main() { syncPageChatFocusRing(); setTimeout(() => { if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (!pageChatInput.value.trim()) collapsePageChat(); scheduleSteerFocusRecover('steer-blur-recover'); }, 120); @@ -7039,7 +8392,7 @@ void main() { globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false'; globalBarBrandEl.setAttribute('aria-label', connected ? 'Impeccable live mode' - : 'Impeccable live mode: agent not polling'); + : 'Impeccable live mode - agent not polling'); globalBarBrandEl.removeAttribute('title'); globalBarBrandEl.style.cursor = connected ? 'default' : 'help'; const mark = globalBarBrandEl.querySelector('[data-brand-mark]'); @@ -7077,7 +8430,7 @@ void main() { }); agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip'; agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP; - document.body.appendChild(agentPollTooltipEl); + uiAppend(agentPollTooltipEl); return agentPollTooltipEl; } @@ -7131,7 +8484,7 @@ void main() { // Custom focus-visible for bar buttons. Browser default is a heavy // blue ring that looks jarring on the dark capsule. Replace with a // soft accent-tinted inner ring that respects the bar's palette. - if (!document.getElementById(PREFIX + '-bar-focus-style')) { + if (!uiGetById(PREFIX + '-bar-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-bar-focus-style'; s.textContent = @@ -7143,7 +8496,7 @@ void main() { '@keyframes impeccable-agent-dot { 0%, 100% { opacity: 0.45; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1); } }' + '#' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: impeccable-agent-dot 1.4s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: none; opacity: 0.9; } }'; - document.head.appendChild(s); + uiAppendStyle(s); } globalBarEl = el('div', { @@ -7176,7 +8529,7 @@ void main() { brand.id = PREFIX + '-global-bar-brand'; brand.dataset.agentConnected = 'false'; brand.setAttribute('role', 'img'); - brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling'); + brand.setAttribute('aria-label', 'Impeccable live mode - agent not polling'); const brandMark = el('span', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -7211,7 +8564,7 @@ void main() { inner.id = PREFIX + '-global-bar-inner'; globalBarEl.appendChild(inner); - // --- button factory: icon-only at rest, label slides in on hover/active --- + // Button factory: icon-only at rest, label slides in on hover/active. function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { const b = el('button', { position: 'relative', @@ -7506,6 +8859,7 @@ void main() { color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', }); + exitBtn.id = PREFIX + '-exit'; exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = 'oklch(58% 0.15 35)'; exitBtn.style.background = P.exitHover; }); @@ -7530,8 +8884,8 @@ void main() { try { window.focus(); } catch { /* in-app preview may block */ } }, true); - document.body.appendChild(pendingDockEl); - document.body.appendChild(globalBarEl); + uiAppend(pendingDockEl); + uiAppend(globalBarEl); defangOutsideHandlers(pendingDockEl); defangOutsideHandlers(globalBarEl); @@ -7553,11 +8907,11 @@ void main() { } function updateGlobalBarState() { - const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); - const detectBadge = document.getElementById(PREFIX + '-detect-badge'); - const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); - const insertToggle = document.getElementById(PREFIX + '-insert-toggle'); - const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const detectToggle = uiGetById(PREFIX + '-detect-toggle'); + const detectBadge = uiGetById(PREFIX + '-detect-badge'); + const pickToggle = uiGetById(PREFIX + '-pick-toggle'); + const insertToggle = uiGetById(PREFIX + '-insert-toggle'); + const designToggle = uiGetById(PREFIX + '-design-toggle'); const theme = globalBarEl?.dataset.theme || 'light'; const P = barPaletteForTheme(theme); @@ -7751,8 +9105,9 @@ void main() { pendingApplyInFlight = false; } if (globalBarEl) { - globalBarEl.style.transform = 'translateY(100%)'; - setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300); + globalBarEl.style.transition = 'none'; + globalBarEl.remove(); + globalBarEl = null; } pageChatEl = null; pageChatInput = null; @@ -7765,6 +9120,7 @@ void main() { if (barEl) { barEl.remove(); barEl = null; } if (pickerEl) { pickerEl.remove(); pickerEl = null; } if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; } + if (editBadgeProxyRoot) { editBadgeProxyRoot.remove(); editBadgeProxyRoot = null; editBadgeProxyByTarget = new Map(); } if (evtSource) { evtSource.close(); evtSource = null; } document.removeEventListener('mousemove', handleMouseMove, true); document.removeEventListener('click', handleClick, true); @@ -7777,9 +9133,9 @@ void main() { console.log('[impeccable] Live mode exited.'); } - // --------------------------------------------------------------------------- + // // Design System Panel - visualizes the project's .impeccable/design.json sidecar - // --------------------------------------------------------------------------- + // const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; const DESIGN_PANEL_WIDTH = 440; @@ -7847,7 +9203,7 @@ void main() { root.className = 'root'; designShadow.appendChild(root); - document.body.appendChild(designHost); + uiAppend(designHost); // The host is pointer-events: none; the panel inside the shadow DOM // manages its own auto/none. Events bubble through the shadow boundary, // so attaching here silences host-page outside-interaction handlers @@ -7889,7 +9245,7 @@ void main() { .root * { box-sizing: border-box; } button { font: inherit; color: inherit; } - /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + /* Panel shell: chrome matches the bar; body canvas stays neutral */ .panel { position: fixed; top: 12px; bottom: 72px; right: 12px; width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); @@ -7955,7 +9311,7 @@ void main() { .panel-body::-webkit-scrollbar { width: 8px; } .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } - /* --- States --- */ + /* States */ .empty, .loading, .error { margin: 16px 4px; padding: 28px 20px; text-align: center; @@ -7966,7 +9322,7 @@ void main() { .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; } .error { color: oklch(45% 0.15 25); } - /* --- Stale hint --- */ + /* Stale hint */ .stale { display: flex; align-items: center; gap: 8px; margin: 8px 4px 12px; @@ -7979,7 +9335,7 @@ void main() { .stale-text { flex: 1; min-width: 0; } .stale-text strong { color: ${DP.ink}; font-weight: 600; } - /* --- Parsed-md fallback banner --- */ + /* Parsed-md fallback banner */ .parsed-md-cta { margin: 8px 4px 14px; padding: 14px 16px; @@ -7991,7 +9347,7 @@ void main() { .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; } .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; } - /* --- Tile primitives --- */ + /* Tile primitives */ .tile { position: relative; background: ${DP.tile}; @@ -8010,7 +9366,7 @@ void main() { } .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; } - /* --- Color tile --- */ + /* Color tile */ .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; } .c-tile:hover { transform: translateY(-1px); } .c-hero { @@ -8025,7 +9381,7 @@ void main() { .c-ramp > span { flex: 1; } .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Type tile --- */ + /* Type tile */ .t-tile { } .t-specimen { margin: 4px 0 6px; @@ -8035,7 +9391,7 @@ void main() { .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; } .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Shadow tile --- */ + /* Shadow tile */ .s-tile { } .s-surface { height: 60px; margin: 8px 2px 10px; @@ -8045,14 +9401,14 @@ void main() { .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; } .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; } - /* --- Radii strip --- */ + /* Radii strip */ .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; } .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; } .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); } .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; } .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; } - /* --- Component tile (hosts live primitives) --- */ + /* Component tile (hosts live primitives) */ .cmp-tile { } .cmp-stage { margin: 12px -4px 0; @@ -8066,7 +9422,7 @@ void main() { .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; } .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; } - /* --- Collapsible --- */ + /* Collapsible */ .coll { margin: 0 4px 8px; background: ${DP.tile}; @@ -8131,7 +9487,7 @@ void main() { .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; } .coll .overview-body li { margin-bottom: 3px; } - /* --- raw tab markdown (unchanged layout, neutralized palette) --- */ + /* raw tab markdown (unchanged layout, neutralized palette) */ .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; } .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; } .md h1 { font-size: 18px; } @@ -8303,7 +9659,7 @@ void main() { return box; } - // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 --- + // Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 function renderDesignVisual(body, parsed, sidecar) { const frontmatter = parsed?.frontmatter || {}; @@ -8667,7 +10023,7 @@ void main() { return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components'); } - // --- Collapsibles --------------------------------------------------------- + // Collapsibles. function buildCollapsible(key, label, count) { const wrap = document.createElement('div'); @@ -8775,7 +10131,7 @@ void main() { return s.replace(/\s+#.*$/, '').trim(); } - // --- Raw tab: minimal markdown renderer (subset) -------------------------- + // Raw tab: minimal markdown renderer (subset) function renderRawTab(body, md) { const wrap = document.createElement('div'); @@ -8908,9 +10264,9 @@ void main() { } catch { /* ignore */ } } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // function init() { try { history.scrollRestoration = 'manual'; } catch {} diff --git a/.trae-cn/skills/impeccable/scripts/live-completion.mjs b/.trae-cn/skills/impeccable/scripts/live-completion.mjs index 86b637fff..986773066 100644 --- a/.trae-cn/skills/impeccable/scripts/live-completion.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-completion.mjs @@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.mode === 'error') return 'error'; + if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error'; return 'agent_done'; } diff --git a/.trae-cn/skills/impeccable/scripts/live-inject.mjs b/.trae-cn/skills/impeccable/scripts/live-inject.mjs index b9d3df41d..3a1f36e46 100644 --- a/.trae-cn/skills/impeccable/scripts/live-inject.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-inject.mjs @@ -17,11 +17,38 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './impeccable-paths.mjs'; +import { + applySvelteKitLiveAdapter, + detectSvelteKitProject, + removeSvelteKitLiveAdapter, +} from './live-sveltekit-adapter.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start'; +const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end'; + +export const LIVE_IGNORE_PATTERNS = Object.freeze([ + '.impeccable/hook.cache.json', + '.impeccable/live/server.json', + '.impeccable/live/sessions/', + '.impeccable/live/previews/', + '.impeccable/live/annotations/', + '.impeccable/live/cache/', + '.impeccable/live/manual-edit-apply-transaction.json', + '.impeccable/live/manual-edit-events.jsonl', + '.impeccable/live/manual-edit-evidence/', + '.impeccable/live/pending-manual-edits.json', + '.impeccable/live/deferred-svelte-component-accepts.json', + '.impeccable-live.json', + '.impeccable-live/', + 'node_modules/.impeccable-live/', + 'src/lib/impeccable/ImpeccableLiveRoot.svelte', + 'src/lib/impeccable/__runtime.js', + 'src/lib/impeccable/[0-9a-f]*/', +]); /** * Hard-excluded directory patterns. These are NEVER user-facing pages and @@ -83,8 +110,14 @@ Output (JSON): validateConfig(config); const resolvedFiles = resolveFiles(process.cwd(), config); + const svelteKit = detectSvelteKitProject(process.cwd(), config); if (args.includes('--remove')) { + if (svelteKit) { + const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config }); + console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; @@ -110,6 +143,13 @@ Output (JSON): console.error(JSON.stringify({ ok: false, error: 'missing_port' })); process.exit(1); } + const gitIgnore = ensureLiveGitIgnores(process.cwd()); + + if (svelteKit) { + const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config }); + console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); @@ -129,10 +169,68 @@ Output (JSON): }; }); const anyInserted = results.some((r) => r.inserted); - console.log(JSON.stringify({ ok: anyInserted, port, results })); + console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results })); if (!anyInserted) process.exit(1); } +export function ensureLiveGitIgnores(cwd = process.cwd()) { + const target = resolveIgnoreTarget(cwd); + const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : ''; + const block = [ + IGNORE_MARKER_OPEN, + ...LIVE_IGNORE_PATTERNS, + IGNORE_MARKER_CLOSE, + ].join('\n'); + const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`); + + let updated; + if (markerRe.test(existing)) { + updated = existing.replace(markerRe, block); + } else { + const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n'; + updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`; + } + + if (updated !== existing) { + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + fs.writeFileSync(target.path, updated, 'utf-8'); + } + + return { + file: path.relative(cwd, target.path).split(path.sep).join('/'), + mode: target.mode, + changed: updated !== existing, + patterns: [...LIVE_IGNORE_PATTERNS], + }; +} + +function resolveIgnoreTarget(cwd) { + const gitExcludePath = resolveGitInfoExcludePath(cwd); + if (gitExcludePath) { + return { path: gitExcludePath, mode: 'git-info-exclude' }; + } + return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' }; +} + +function resolveGitInfoExcludePath(cwd) { + const dotGit = path.join(cwd, '.git'); + if (!fs.existsSync(dotGit)) return null; + + const stat = fs.statSync(dotGit); + if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude'); + if (!stat.isFile()) return null; + + const body = fs.readFileSync(dotGit, 'utf-8').trim(); + const match = body.match(/^gitdir:\s*(.+)$/i); + if (!match) return null; + const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]); + return path.join(gitDir, 'info', 'exclude'); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Expand config.files (which may contain glob patterns) into a literal list * of existing file paths relative to rootDir. Literal entries pass through; diff --git a/.trae-cn/skills/impeccable/scripts/live-insert.mjs b/.trae-cn/skills/impeccable/scripts/live-insert.mjs index 09d4d55be..0658e9914 100644 --- a/.trae-cn/skills/impeccable/scripts/live-insert.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-insert.mjs @@ -21,6 +21,11 @@ import { buildCssAuthoring, buildCssSelectorPrefixExamples, } from './live-wrap.mjs'; +import { + buildSvelteComponentCssAuthoring, + scaffoldSvelteComponentInsertSession, + shouldUseSvelteComponentInjection, +} from './live-svelte-component.mjs'; const INSERT_POSITIONS = new Set(['before', 'after']); @@ -192,6 +197,41 @@ Output (JSON): const styleMode = detectStyleMode(targetFile); const isJsx = commentSyntax.open === '{/*'; const spliceIndex = computeInsertLine(startLine, endLine, position); + const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); + + if (shouldUseSvelteComponentInjection(targetFile)) { + const session = scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile: relTargetFile, + insertLine: spliceIndex + 1, + position, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + anchorLines: lines.slice(startLine, endLine + 1), + cwd: process.cwd(), + }); + console.log(JSON.stringify({ + mode: 'insert', + position, + file: session.manifestFile, + sourceFile: relTargetFile, + previewMode: 'svelte-component', + componentDir: session.componentDir, + propContract: session.propContract, + insertLine: 1, + sourceInsertLine: spliceIndex + 1, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + commentSyntax, + styleMode: 'svelte-component', + styleTag: null, + cssSelectorPrefixExamples: [], + cssAuthoring: buildSvelteComponentCssAuthoring(count), + })); + return; + } + const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? ''; @@ -216,7 +256,7 @@ Output (JSON): console.log(JSON.stringify({ mode: 'insert', position, - file: path.relative(process.cwd(), targetFile), + file: relTargetFile, insertLine: insertLine + 1, commentSyntax, styleMode: styleMode.mode, diff --git a/.trae-cn/skills/impeccable/scripts/live-poll.mjs b/.trae-cn/skills/impeccable/scripts/live-poll.mjs index fad836612..1e1259fbc 100644 --- a/.trae-cn/skills/impeccable/scripts/live-poll.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-poll.mjs @@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs'; // that ceiling and loop in `pollOnce` to synthesize a long poll without // depending on the standalone undici package. export const PER_REQUEST_TIMEOUT_MS = 270_000; +export const DEFAULT_EVENT_LEASE_MS = 600_000; const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); @@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) { ? totalDeadline - Date.now() : PER_REQUEST_TIMEOUT_MS; const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); - const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); + const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`); if (res.status === 401) { const err = new Error('Authentication failed. The server token may have changed.'); @@ -317,7 +318,7 @@ Modes: Options: --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) - --file PATH Attach a source file path to the reply (generate flow) + --file PATH Attach a source file path to the reply (generate/steer flow) --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --help Show this help message diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs index 16c8285b9..cd1091b88 100644 --- a/.trae-cn/skills/impeccable/scripts/live-server.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs @@ -42,6 +42,10 @@ import { } from './live-manual-edits-buffer.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs'; +import { + applyDeferredSvelteComponentAccepts, + removeAllSvelteComponentSessions, +} from './live-svelte-component.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated @@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; +const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2; const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); function tombstoneTimedOutApplyId(eventId, details = {}) { @@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) { return entry.event; } entry.leaseUntil = Date.now() + leaseMs; + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return entry.event; } @@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) { const acknowledged = state.pendingEvents[idx].event; state.pendingEvents.splice(idx, 1); scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return acknowledged; } +function findPendingEventById(id) { + if (!id) return null; + const entry = state.pendingEvents.find((item) => item.event?.id === id); + return entry?.event || null; +} + function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; return `live-poll.mjs --reply ${id} done --data ''`; @@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) { return summary; } +function summarizeActiveSessionForClient(snapshot = {}) { + return { + id: snapshot.id, + phase: snapshot.phase, + pageUrl: snapshot.pageUrl ?? null, + sourceFile: snapshot.sourceFile ?? null, + previewFile: snapshot.previewFile ?? null, + previewMode: snapshot.previewMode ?? null, + expectedVariants: snapshot.expectedVariants ?? 0, + arrivedVariants: snapshot.arrivedVariants ?? 0, + visibleVariant: snapshot.visibleVariant ?? null, + checkpointRevision: snapshot.checkpointRevision ?? 0, + paramValues: snapshot.paramValues || {}, + }; +} + +function activeSessionSummaries() { + if (!state.sessionStore) return []; + return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot)); +} + +function cancelQueuedAnonymousExitEvents() { + let removed = 0; + for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) { + const event = state.pendingEvents[i]?.event; + if (event?.type !== 'exit' || event.id) continue; + state.pendingEvents.splice(i, 1); + removed += 1; + } + if (removed > 0) { + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); + } + return removed; +} + function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { const canceledById = new Map(); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); @@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() { clearTimeout(state.leaseTimer); state.leaseTimer = null; } - if (state.pendingPolls.length === 0) return; const now = Date.now(); const nextLeaseUntil = state.pendingEvents .map((entry) => entry.leaseUntil || 0) @@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() { state.leaseTimer = setTimeout(() => { state.leaseTimer = null; flushPendingPolls(); - }, Math.max(0, nextLeaseUntil - now)); + broadcastAgentPollingIfChanged(); + }, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS)); } function flushPendingPolls() { @@ -1032,7 +1082,9 @@ function flushPendingPolls() { } function agentPollingConnected() { - return state.pendingPolls.length > 0; + const now = Date.now(); + return state.pendingPolls.length > 0 + || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now); } function broadcastAgentPollingIfChanged() { @@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/status') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } - const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; + const sessions = activeSessionSummaries(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', @@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/events' && req.method === 'GET') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + clearTimeout(state.exitTimer); + state.exitTimer = null; + cancelQueuedAnonymousExitEvents(); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { type: 'connected', hasProjectContext: hasProjectContext(), agentPolling: agentPollingConnected(), + activeSessions: activeSessionSummaries(), }) + '\n\n'); state.sseClients.add(res); - clearTimeout(state.exitTimer); // Keepalive: SSE comment every 30s prevents silent connection drops. const heartbeat = setInterval(() => { @@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } } + if (msg.type === 'exit') { + cleanupSvelteComponentSessionsBeforeExit(); + } if (msg.type !== 'checkpoint') { enqueueEvent(msg); } @@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) { }); } +function sessionFileMetadataFromPollReply(file) { + if (!file || typeof file !== 'string') return { file }; + const normalized = file.split(path.sep).join('/'); + const base = { file: normalized }; + if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base; + if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base; + + let full; + try { + full = path.resolve(process.cwd(), normalized); + const rel = path.relative(process.cwd(), full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base; + } catch { + return base; + } + + try { + const manifest = JSON.parse(fs.readFileSync(full, 'utf-8')); + if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base; + return { + file: String(manifest.sourceFile).split(path.sep).join('/'), + sourceFile: String(manifest.sourceFile).split(path.sep).join('/'), + previewFile: normalized, + previewMode: 'svelte-component', + }; + } catch { + return base; + } +} + function handlePollPost(req, res) { let body = ''; req.on('data', (c) => { body += c; }); @@ -1965,6 +2053,16 @@ function handlePollPost(req, res) { res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); return; } + const pendingEventBeforeAck = findPendingEventById(msg.id); + if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done' + && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'steer_done_requires_file_or_message', + hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.', + })); + return; + } const acknowledgedEvent = acknowledgePendingEvent(msg.id); let skipJournalReply = false; let existingSession = null; @@ -1987,6 +2085,7 @@ function handlePollPost(req, res) { })); return; } + const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); if (state.sessionStore && msg.id && !skipJournalReply) { try { const eventType = msg.type === 'steer_done' @@ -2001,7 +2100,10 @@ function handlePollPost(req, res) { state.sessionStore.appendEvent({ type: eventType, id: msg.id, - file: msg.file, + file: replyFileMeta.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, message: msg.message, sourceEventType: acknowledgedEvent?.type, carbonize: msg.data?.carbonize === true, @@ -2010,7 +2112,16 @@ function handlePollPost(req, res) { } flushPendingPolls(); // Forward the reply to the browser via SSE - broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + broadcast({ + type: msg.type || 'done', + id: msg.id, + message: msg.message, + file: msg.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, + data: msg.data, + }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); @@ -2023,6 +2134,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { + cleanupSvelteComponentSessionsBeforeExit(); removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; @@ -2037,6 +2149,25 @@ function shutdown() { process.exit(0); } +function cleanupSvelteComponentSessionsBeforeExit() { + try { + removeAllSvelteComponentSessions(process.cwd()); + } catch (err) { + console.warn('[impeccable] Svelte component session cleanup failed:', err.message); + } +} + +function applyLegacyDeferredAcceptsOnStartup() { + try { + const result = applyDeferredSvelteComponentAccepts(process.cwd()); + if (result.applied > 0 || result.failed > 0) { + console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result)); + } + } catch (err) { + console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({ cwd: process.cwd(), reason: 'manual_edit_server_start_recovered_abandoned_transaction', }); +applyLegacyDeferredAcceptsOnStartup(); restorePendingEventsFromStore(); pruneStaleManualApplyEvidence(process.cwd()); const portArg = args.find(a => a.startsWith('--port=')); diff --git a/.trae-cn/skills/impeccable/scripts/live-session-store.mjs b/.trae-cn/skills/impeccable/scripts/live-session-store.mjs index 7562e3d5b..5ec4d34d4 100644 --- a/.trae-cn/skills/impeccable/scripts/live-session-store.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-session-store.mjs @@ -106,6 +106,8 @@ function baseSnapshot(id) { phase: 'new', pageUrl: null, sourceFile: null, + previewFile: null, + previewMode: null, expectedVariants: 0, arrivedVariants: 0, visibleVariant: null, @@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; - next.sourceFile = event.file ?? next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0); next.pendingEventSeq = null; next.pendingEvent = null; if (event.carbonize === true) { @@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { } break; case 'checkpoint': + if (COMPLETED_PHASES.has(next.phase)) { + next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null }); + break; + } if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { next.phase = event.phase ?? next.phase; next.checkpointRevision = event.revision ?? next.checkpointRevision; next.activeOwner = event.owner ?? next.activeOwner; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.visibleVariant = event.visibleVariant ?? next.visibleVariant; + next.sourceFile = event.sourceFile ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; if (event.paramValues) next.paramValues = { ...event.paramValues }; } else { next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); @@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'steer_done': next.phase = 'steer_done'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.message = event.message ?? next.message; next.pendingEventSeq = null; next.pendingEvent = null; break; @@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'complete': next.phase = 'completed'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; next.pendingEventSeq = null; next.pendingEvent = null; break; diff --git a/.trae-cn/skills/impeccable/scripts/live-svelte-component.mjs b/.trae-cn/skills/impeccable/scripts/live-svelte-component.mjs new file mode 100644 index 000000000..dc35dc0ce --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live-svelte-component.mjs @@ -0,0 +1,826 @@ +/** + * Svelte live-mode component injection helpers. + * + * Variants are real .svelte components under node_modules/.impeccable-live//. + * The browser mounts them via Svelte 5 mount(); accept inlines the chosen + * variant back into the route source with props mapped to original bindings. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; + +export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live'; +export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`; +export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json'; + +const MUSTACHE_RE = /\{([^{}]+)\}/g; + +export function shouldUseSvelteComponentInjection(filePath) { + if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false; + return path.extname(filePath).toLowerCase() === '.svelte'; +} + +export function componentSessionDir(id, cwd = process.cwd()) { + return path.join(cwd, SVELTE_COMPONENT_ROOT, id); +} + +export function manifestPathForSession(id, cwd = process.cwd()) { + return path.join(componentSessionDir(id, cwd), 'manifest.json'); +} + +export function ensureRuntimeHelper(cwd = process.cwd()) { + const file = path.join(cwd, SVELTE_RUNTIME_FILE); + if (fs.existsSync(file)) return file; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8'); + return file; +} + +/** + * Extract ordered unique mustache expressions from markup (not inside ). + */ +export function extractMustacheExpressions(text) { + const expressions = []; + const seen = new Set(); + const lines = String(text || '').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('\n` + : ''; + return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n\n`; +} + +function buildInsertVariantStub(variantNum) { + return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; +} + +export function scaffoldSvelteComponentSession({ + id, + count, + sourceFile, + sourceStartLine, + sourceEndLine, + originalLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const originalMarkup = originalLines.join('\n'); + const contract = buildPropContract(extractMustacheExpressions(originalMarkup)); + const originalWithProps = substituteExprsWithProps(originalMarkup, contract); + + const manifest = { + id, + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + sourceStartLine, + sourceEndLine, + count, + propContract: contract, + originalMarkup, + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: contract, + }; +} + +export function scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile, + insertLine, + position, + anchorStartLine, + anchorEndLine, + anchorLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const anchorMarkup = (anchorLines || []).join('\n'); + const manifest = { + id, + mode: 'insert', + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + insertLine, + position, + anchorStartLine, + anchorEndLine, + originalMarkup: anchorMarkup, + anchorMarkup, + count, + propContract: [], + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: [], + }; +} + +export function findSvelteComponentManifest(id, cwd = process.cwd()) { + const direct = manifestPathForSession(id, cwd); + if (fs.existsSync(direct)) { + return readManifest(direct); + } + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return null; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(root, entry.name, 'manifest.json'); + if (!fs.existsSync(candidate)) continue; + try { + const manifest = readManifest(candidate); + if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; + } catch { /* skip */ } + } + return null; +} + +export function readManifest(manifestPath) { + const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + return { + ...data, + manifestPath, + }; +} + +export function resolveSourceFile(sourceFile, cwd = process.cwd()) { + if (!sourceFile || path.isAbsolute(sourceFile)) { + throw new Error('Invalid svelte-component source file'); + } + const full = path.resolve(cwd, sourceFile); + const rel = path.relative(cwd, full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('Svelte-component source file escapes project root'); + } + if (!fs.existsSync(full)) { + throw new Error('Svelte-component source file not found: ' + sourceFile); + } + return full; +} + +function appendCssToSvelteStyle(lines, cssLines) { + const closeIdx = findLastStyleCloseLine(lines); + const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; + if (closeIdx === -1) { + return [...lines, '', '']; + } + return [ + ...lines.slice(0, closeIdx), + ...prepared, + ...lines.slice(closeIdx), + ]; +} + +function findLastStyleCloseLine(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + if (/<\/style\s*>/.test(lines[i])) return i; + } + return -1; +} + +function bakeParamValuesInCss(cssLines, paramValues) { + if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; + return cssLines.map((line) => { + let out = line; + for (const [key, value] of Object.entries(paramValues)) { + const varName = `--p-${key}`; + out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); + } + return out; + }); +} + +function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { + const css = String((cssLines || []).join('\n')); + if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; + + const rules = parseCssRules(css); + const output = []; + for (const rule of rules) { + appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); + } + return output.join('\n') + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ''); +} + +function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { + const prelude = rule.prelude.trim(); + const body = rule.body.trim(); + if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; + + if (/^@scope\b/i.test(prelude)) { + if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; + const inner = parseCssRules(body); + for (const innerRule of inner) { + const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); + if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; + output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); + } + return; + } + + const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); + if (!rewrittenPrelude) return; + output.push(formatCssRule(rewrittenPrelude, body)); +} + +function parseCssRules(css) { + const rules = []; + const text = String(css || ''); + let i = 0; + while (i < text.length) { + while (i < text.length && /\s/.test(text[i])) i++; + const preludeStart = i; + while (i < text.length && text[i] !== '{') i++; + if (i >= text.length) break; + const prelude = text.slice(preludeStart, i).trim(); + i++; + const bodyStart = i; + let depth = 1; + let quote = null; + let comment = false; + while (i < text.length && depth > 0) { + const ch = text[i]; + const next = text[i + 1]; + if (comment) { + if (ch === '*' && next === '/') { + comment = false; + i += 2; + continue; + } + i++; + continue; + } + if (quote) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === quote) quote = null; + i++; + continue; + } + if (ch === '/' && next === '*') { + comment = true; + i += 2; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); + if (prelude) rules.push({ prelude, body }); + } + return rules; +} + +function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { + const selectors = splitSelectorList(prelude); + const rewritten = []; + for (const selector of selectors) { + const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); + if (next) rewritten.push(next); + } + return rewritten.join(', '); +} + +function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { + let out = selector.trim(); + const hasVariant = /data-impeccable-variant/.test(out); + if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; + if (hasVariant) { + out = out.replace(variantSelectorRegex(variantNum), ''); + out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); + } + + const paramResult = rewriteParamSelectors(out, paramValues); + if (!paramResult.keep) return ''; + out = paramResult.selector; + + out = out + .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') + .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') + .replace(/\s+/g, ' ') + .trim(); + + out = out.replace(/^[>+~]\s*/, '').trim(); + if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; + return out; +} + +function rewriteParamSelectors(selector, paramValues) { + let keep = true; + const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { + if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; + const actual = paramValues[key]; + if (expected != null && String(actual) !== String(expected)) { + keep = false; + return ''; + } + if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { + keep = false; + return ''; + } + return ''; + }); + return { keep, selector: next }; +} + +function splitSelectorList(prelude) { + const selectors = []; + let start = 0; + let bracket = 0; + let paren = 0; + let quote = null; + for (let i = 0; i < prelude.length; i++) { + const ch = prelude[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '[') bracket++; + else if (ch === ']') bracket = Math.max(0, bracket - 1); + else if (ch === '(') paren++; + else if (ch === ')') paren = Math.max(0, paren - 1); + else if (ch === ',' && bracket === 0 && paren === 0) { + selectors.push(prelude.slice(start, i)); + start = i + 1; + } + } + selectors.push(prelude.slice(start)); + return selectors; +} + +function selectorHasVariant(selector, variantNum) { + return variantSelectorRegex(variantNum).test(selector); +} + +function variantSelectorRegex(variantNum) { + return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); +} + +function formatCssRule(selector, body) { + return `${selector} { ${body.trim()} }`; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { + const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); + const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); + const resultBase = { + file: manifest.sourceFile, + sourceFile: manifest.sourceFile, + previewMode: 'svelte-component', + componentDir: manifest.componentDir, + carbonize: false, + }; + if (!fs.existsSync(variantPath)) { + return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; + } + + const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); + if (manifest.mode === 'insert') { + return inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, + }); + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const contract = manifest.propContract || []; + const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); + const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract) + .split('\n') + .map((line) => line.trimEnd()); + + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const start = Number(manifest.sourceStartLine) - 1; + const end = Number(manifest.sourceEndLine) - 1; + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { + return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; + } + + const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, start), + ...indentedMarkup, + ...sourceLines.slice(end + 1), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, +}) { + if (!svelteMarkupHasVisibleContent(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; + } + if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const restoredMarkup = String(markup || '') + .split('\n') + .map((line) => line.trimEnd()); + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const insertIndex = Number(manifest.insertLine) - 1; + if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { + return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; + } + + const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; + const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, insertIndex), + ...indentedMarkup, + ...sourceLines.slice(insertIndex), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function svelteMarkupHasVisibleContent(markup) { + const text = String(markup || '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 0) return true; + return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); +} + +function mergeOriginalTopLevelAttrs(markup, originalMarkup) { + const variantOpen = matchOpeningTag(markup); + const originalOpen = matchOpeningTag(originalMarkup); + if (!variantOpen || !originalOpen) return markup; + if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; + + const variantAttrs = parseAttrSegments(variantOpen.attrs); + const originalAttrs = parseAttrSegments(originalOpen.attrs); + const additions = []; + let attrs = variantOpen.attrs; + + const originalClass = originalAttrs.get('class'); + const variantClass = variantAttrs.get('class'); + if (originalClass && variantClass) { + const merged = mergeStaticClassAttr(originalClass, variantClass); + if (merged) { + attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); + variantAttrs.set('class', { ...variantClass, raw: merged }); + } + } else if (originalClass && !variantClass) { + additions.push(originalClass.raw); + } + + for (const [name, attr] of originalAttrs) { + if (name === 'class') continue; + if (!variantAttrs.has(name)) additions.push(attr.raw); + } + + if (additions.length === 0 && attrs === variantOpen.attrs) return markup; + const nextOpen = variantOpen.prefix + + variantOpen.tag + + attrs + + additions.map((attr) => ' ' + attr.trim()).join('') + + variantOpen.close; + return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); +} + +function matchOpeningTag(markup) { + const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); + if (!match) return null; + return { + raw: match[0], + prefix: match[1], + tag: match[2], + attrs: match[3] || '', + close: match[4], + index: match.index || 0, + }; +} + +function parseAttrSegments(attrs) { + const out = new Map(); + const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; + let match; + while ((match = re.exec(attrs))) { + const raw = match[0]; + const name = match[1]; + out.set(name, { + name, + raw, + start: match.index, + end: match.index + raw.length, + }); + } + return out; +} + +function mergeStaticClassAttr(originalClass, variantClass) { + const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + if (!originalValue || !variantValue) return null; + const quote = variantValue[1]; + const classes = [ + ...variantValue[2].split(/\s+/), + ...originalValue[2].split(/\s+/), + ].filter(Boolean); + return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; +} + +export function removeSvelteComponentSession(id, cwd = process.cwd()) { + const dir = componentSessionDir(id, cwd); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* non-fatal */ } +} + +export function removeAllSvelteComponentSessions(cwd = process.cwd()) { + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('__')) continue; + try { + fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + } catch { /* non-fatal */ } + } +} + +export function deferredAcceptsPath(cwd = process.cwd()) { + const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16); + return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json'); +} + +export function readDeferredAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch { + return { accepts: [] }; + } +} + +export function writeDeferredAccept(entry, cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const data = readDeferredAccepts(cwd); + data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id); + data.accepts.push({ ...entry, createdAt: new Date().toISOString() }); + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + const data = readDeferredAccepts(cwd); + const pending = Array.isArray(data.accepts) ? data.accepts : []; + const results = []; + const remaining = []; + for (const entry of pending) { + try { + const manifest = findSvelteComponentManifest(entry.id, cwd); + if (!manifest) { + results.push({ id: entry.id, ok: false, error: 'manifest not found' }); + remaining.push(entry); + continue; + } + const result = inlineSvelteComponentAccept( + manifest, + entry.variantNum, + entry.paramValues || null, + cwd, + ); + results.push({ id: entry.id, ok: result.handled !== false, result }); + if (result.handled === false) remaining.push(entry); + } catch (err) { + results.push({ id: entry.id, ok: false, error: err.message }); + remaining.push(entry); + } + } + if (remaining.length > 0) { + fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8'); + } else { + try { fs.rmSync(file, { force: true }); } catch {} + } + return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results }; +} + +export function buildSvelteComponentCssAuthoring(count) { + const variantNumbers = Array.from({ length: count }, (_, i) => i + 1); + return { + mode: 'svelte-component', + styleTag: null, + strategy: 'component-style-block', + rulePattern: '.semantic-class { ... }', + selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'), + requirements: [ + 'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).', + 'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.', + 'Put variant CSS in the component ' : '')); + if (paramValues && Object.keys(paramValues).length > 0) { + lines.push( + bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close, + ); + } + lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + lines.push(bodyIndent + '
'); + lines.push(...bodyRestored); + lines.push(bodyIndent + '
'); + }; + + if (isJsx) { + const wrapperStyle = 'style={{ display: "contents" }}'; + lines.push(indent + '
'); + pushCarbonizeBody(indent + ' '); + lines.push(indent + '
'); + } else { + pushCarbonizeBody(indent); + } + + return lines; +} + +function reindentContent(contentLines, fromIndent, toIndent) { + return contentLines.map((line) => { + if (line.trim() === '') return ''; + if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length); + return toIndent + line.trimStart(); + }); +} + function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; @@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const needsCarbonize = !!(cssContent || hasHelperAttrs); - // Build the replacement const restored = deindentContent(variantContent, indent); - const replacement = []; - - if (cssContent) { - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); - // JSX targets need the CSS body wrapped in a template literal so that the - // `{` and `}` in CSS rules don't get parsed as JSX expressions. - replacement.push(indent + '' : '')); - if (paramValues && Object.keys(paramValues).length > 0) { - // Preserve the user's knob positions for the carbonize-cleanup agent - // to bake into the final CSS when it collapses scoped rules. - replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); - } - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); - } - - // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the - // carbonize CSS block working visually by re-wrapping the accepted content - // in a data-impeccable-variant="N" div with `display: contents` (so layout - // isn't affected). The carbonize agent strips this attribute + wrapper when - // it moves the CSS to a proper stylesheet. - // - // Style attribute syntax has to follow the host file's flavor — JSX files - // need the object form, otherwise React 19 throws "Failed to set indexed - // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. - if (cssContent) { - const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; - replacement.push(indent + '
'); - replacement.push(...restored); - replacement.push(indent + '
'); - } else { - replacement.push(...restored); - } + const replacement = buildCarbonizeReplacement({ + indent, + commentSyntax, + isJsx, + id, + variantNum, + cssContent, + paramValues, + restored, + }); const newLines = [ ...lines.slice(0, replaceRange.start), @@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; } +function readSourceShadowPreviewMeta(content, id) { + const escaped = escapeRegExp(id); + const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>'); + const match = String(content || '').match(wrapperRe); + if (!match) return null; + const tag = match[0]; + if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null; + const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file'); + const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start')); + const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end')); + if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null; + return { sourceFile, sourceStartLine, sourceEndLine }; +} + +function readHtmlAttr(tag, name) { + const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1')); + if (!match) return null; + return decodeHtmlAttr(match[2]); +} + +function decodeHtmlAttr(value) { + return String(value || '') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- @@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts }; diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index f609ac847..d7c580e2f 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -26,9 +26,9 @@ return; } - // --------------------------------------------------------------------------- + // // Design tokens - // --------------------------------------------------------------------------- + // // Brand kinpaku (gold) is pinned to the site's neo-kinpaku tokens // (see site/styles/kinpaku-tokens.css) so Accept / knobs / cycle-dots / @@ -115,19 +115,48 @@ { value: 'overdrive', label: 'Overdrive' }, ]; - // --------------------------------------------------------------------------- + const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions']; + const LIVE_UI_SURFACES = [ + { 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'] }, + { key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] }, + { key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-configure-input-wrap', PREFIX + '-input', PREFIX + '-configure-voice'] }, + { key: 'action-picker', ids: [PREFIX + '-picker'] }, + { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, + { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, + { key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] }, + { key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] }, + { key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] }, + { 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'] }, + { key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] }, + { key: 'design-system-panel', ids: [PREFIX + '-design-host'] }, + { key: 'toasts-and-errors', ids: [PREFIX + '-toast'] }, + { key: 'css-isolation-boundary', ids: [PREFIX + '-root'] }, + ]; + const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))]; + + // // State - // --------------------------------------------------------------------------- + // let state = 'IDLE'; let hoveredElement = null; let selectedElement = null; let currentSessionId = null; - let pendingAcceptedSession = null; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; + let svelteComponentSession = null; + let svelteRuntimePromise = null; + let pendingSvelteComponentRetryObserver = null; + let currentSourceFile = null; + let currentPreviewFile = null; + let currentPreviewMode = null; + let recoveryWaitingForAnchor = false; + let pendingAcceptedSession = null; let variantObserver = null; + let variantSelectionInFlight = false; + let variantSelectionPromise = null; + let recoveringEmptyCycling = false; let hasProjectContext = false; let selectedAction = 'impeccable'; let selectedCount = 3; @@ -175,14 +204,17 @@ let highlightEl = null; let tooltipEl = null; let barEl = null; + let barHideSeq = 0; let pickerEl = null; let toastEl = null; let scrollRaf = null; let editBadgeEl = null; + let editBadgeProxyRoot = null; + let editBadgeProxyByTarget = new Map(); - // --------------------------------------------------------------------------- + // // Helpers - // --------------------------------------------------------------------------- + // function own(el) { return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); @@ -204,8 +236,105 @@ return s; } + function rectIsUsableAnchor(rect) { + return !!rect && rect.width > 0.5 && rect.height > 0.5; + } + + function makeFrozenAnchor(el) { + if (!el || !el.getBoundingClientRect) return null; + const r = el.getBoundingClientRect(); + if (!rectIsUsableAnchor(r)) return null; + const rect = { + x: r.x, y: r.y, + top: r.top, left: r.left, + right: r.right, bottom: r.bottom, + width: r.width, height: r.height, + }; + return { + __impeccableFrozenAnchor: true, + tagName: el.tagName || 'DIV', + id: el.id || '', + classList: el.classList ? [...el.classList] : [], + hasAttribute: () => false, + getBoundingClientRect: () => rect, + }; + } + function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } + function cssId(id) { + if (window.CSS?.escape) return CSS.escape(id); + return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); + } + + function liveUiRoot() { + const root = window.__IMPECCABLE_LIVE_UI_ROOT__; + if (root && typeof root.appendChild === 'function') return root; + return document.body; + } + + function uiAppend(el) { + liveUiRoot().appendChild(el); + return el; + } + + function uiAppendStyle(styleEl) { + const root = liveUiRoot(); + if (root && root !== document.body) root.appendChild(styleEl); + else document.head.appendChild(styleEl); + return styleEl; + } + + function uiGetById(id) { + const root = liveUiRoot(); + if (root?.getElementById) { + const found = root.getElementById(id); + if (found) return found; + } + if (root?.querySelector) { + const found = root.querySelector('#' + cssId(id)); + if (found) return found; + } + return document.getElementById(id); + } + + function activeElementDeep() { + let active = document.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; + return active; + } + + window.__IMPECCABLE_LIVE_CHROME_CORE__ = { + version: 1, + adapter: window.__IMPECCABLE_LIVE_ADAPTER__ || 'dom', + mountContract: LIVE_CHROME_MOUNT_CONTRACT, + surfaces: LIVE_UI_SURFACES, + componentIds: LIVE_UI_COMPONENT_IDS, + root: liveUiRoot, + append: uiAppend, + appendStyle: uiAppendStyle, + getById: uiGetById, + activeElementDeep, + debugState: () => ({ + state, + currentSessionId, + expectedVariants, + arrivedVariants, + visibleVariant, + savedSession: loadSession(), + sourceFile: currentSourceFile, + previewFile: currentPreviewFile, + previewMode: currentPreviewMode, + barText: barEl?.textContent || null, + barConnected: !!barEl?.isConnected, + hasSvelteComponentSession: !!svelteComponentSession, + mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0, + pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver, + recoveryWaitingForAnchor, + evtSourceReadyState: evtSource ? evtSource.readyState : null, + }), + }; + // Modal-aware chrome: keep our floating UI clickable inside Radix / // Headless UI / vaul portals. // @@ -245,9 +374,9 @@ rootEl.addEventListener('focusin', stop); } - // --------------------------------------------------------------------------- + // // Highlight overlay - // --------------------------------------------------------------------------- + // function initHighlight() { highlightEl = document.createElement('div'); @@ -259,7 +388,7 @@ transition: HIGHLIGHT_TRANSITION, display: 'none', opacity: '0', }); - document.body.appendChild(highlightEl); + uiAppend(highlightEl); tooltipEl = document.createElement('div'); tooltipEl.id = PREFIX + '-tooltip'; @@ -273,7 +402,7 @@ letterSpacing: '0.02em', transition: TOOLTIP_TRANSITION, }); - document.body.appendChild(tooltipEl); + uiAppend(tooltipEl); } function showHighlight(el) { @@ -310,7 +439,7 @@ if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; } } - // --------------------------------------------------------------------------- + // // Annotation overlay (comment pins + kinpaku strokes) // // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned @@ -318,7 +447,7 @@ // drag) drops a comment pin; drag paints a kinpaku SVG stroke. All coords // are stored in element-local CSS px so they survive scroll / resize and // correlate directly with the captured PNG. - // --------------------------------------------------------------------------- + // const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it @@ -397,7 +526,7 @@ annotOverlayEl.addEventListener('pointermove', onAnnotMove); annotOverlayEl.addEventListener('pointerup', onAnnotUp); annotOverlayEl.addEventListener('pointercancel', onAnnotUp); - document.body.appendChild(annotOverlayEl); + uiAppend(annotOverlayEl); // Modal-host friendliness: pointer-events is already 'auto' on this // overlay; we only need to silence the host's outside-interaction // listeners. Don't override pointer-events here (the overlay toggles @@ -828,9 +957,9 @@ return wrap; } - // --------------------------------------------------------------------------- + // // Element context extraction - // --------------------------------------------------------------------------- + // function stripManualEditRuntimeState(root) { if (!root || root.nodeType !== 1) return; @@ -971,9 +1100,9 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } - // --------------------------------------------------------------------------- + // // The Bar - one floating element, three modes - // --------------------------------------------------------------------------- + // // Contextual-bar palette. Cached at init so every build*Row reads a // consistent set of colors; detectPageTheme runs once rather than on every @@ -1006,7 +1135,7 @@ padding: '6px', maxWidth: '520px', minWidth: '320px', }); - document.body.appendChild(barEl); + uiAppend(barEl); defangOutsideHandlers(barEl); } @@ -1041,6 +1170,8 @@ } function showBar(mode) { + barHideSeq += 1; + if (mode === 'cycling' && !ensureCyclingRenderable('show-bar')) return; barEl.innerHTML = ''; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); @@ -1058,11 +1189,12 @@ function hideBar() { if (!barEl) return; + const hideSeq = ++barHideSeq; stopVoice({ suppressSubmit: true }); if (configureKind === 'insert') clearInsertPicking(); barEl.style.opacity = '0'; barEl.style.transform = 'translateY(6px)'; - setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); + setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); if (state === 'EDITING') restoreInlineEditDrafts(); @@ -1071,6 +1203,7 @@ function updateBarContent(mode) { if (!barEl || barEl.style.display === 'none') return; + if (mode === 'cycling' && !ensureCyclingRenderable('update-bar')) return; barEl.innerHTML = ''; // Reset bar styling to the kinpaku picker palette barEl.style.background = BP.surface; @@ -1090,13 +1223,13 @@ syncPageChatFocus('update-bar-content'); } - // --- Configure row --- + // Configure row function syncConfigureInputChrome() { - const wrap = document.getElementById(PREFIX + '-configure-input-wrap'); - const input = document.getElementById(PREFIX + '-input'); + const wrap = uiGetById(PREFIX + '-configure-input-wrap'); + const input = uiGetById(PREFIX + '-input'); if (!wrap || !input) return; - const focused = document.activeElement === input; + const focused = activeElementDeep() === input; wrap.dataset.inputFocused = focused ? 'true' : 'false'; wrap.dataset.voiceListening = (voiceListening && voiceCtx?.mode === 'configure') ? 'true' : 'false'; wrap.style.borderColor = (voiceListening && voiceCtx?.mode === 'configure') @@ -1104,7 +1237,7 @@ : (focused ? BP.accentSoft : BP.hairline); } - // --- Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) --- + // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) function detectInsertAxisFromStyle(style) { const display = style?.display || 'block'; @@ -1377,7 +1510,7 @@ display: 'none', opacity: '0.9', }); - document.body.appendChild(insertLineEl); + uiAppend(insertLineEl); defangOutsideHandlers(insertLineEl); return insertLineEl; } @@ -1440,6 +1573,10 @@ /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { + if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor) return anchor; + } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (wrapper) { @@ -1557,6 +1694,11 @@ positionBar(); } + function showOrUpdateCyclingBar() { + if (barEl && barEl.style.display !== 'none') updateBarContent('cycling'); + else showBar('cycling'); + } + function buildPlaceholderResizeHandles() { if (!placeholderResizeLayerEl) return; placeholderResizeLayerEl.innerHTML = ''; @@ -1665,7 +1807,7 @@ } function isInsertCreateEnabled(btn) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); + btn = btn || uiGetById(PREFIX + '-insert-create'); return !!btn && btn.getAttribute('aria-disabled') !== 'true'; } @@ -1691,7 +1833,7 @@ lineHeight: '1.35', }); insertCreateTooltipEl.id = PREFIX + '-insert-create-tooltip'; - document.body.appendChild(insertCreateTooltipEl); + uiAppend(insertCreateTooltipEl); return insertCreateTooltipEl; } @@ -1723,8 +1865,8 @@ } function syncInsertCreateButton(btn, input) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); - input = input || document.getElementById(PREFIX + '-insert-input'); + btn = btn || uiGetById(PREFIX + '-insert-create'); + input = input || uiGetById(PREFIX + '-insert-input'); if (!btn || !input) return; const gate = insertCreateGateState(input); const ok = canCreateInsert(gate); @@ -1833,7 +1975,7 @@ voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!document.getElementById(PREFIX + '-configure-input-style')) { + if (!uiGetById(PREFIX + '-configure-input-style')) { const s = document.createElement('style'); s.id = PREFIX + '-configure-input-style'; s.textContent = @@ -1842,7 +1984,7 @@ '#' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: impeccable-configure-voice-pulse 1.1s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-configure-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } input.addEventListener('focus', () => syncConfigureInputChrome()); @@ -1949,6 +2091,9 @@ transition: 'border-color 0.15s ease', }); inputWrap.id = PREFIX + '-insert-input-wrap'; + inputWrap.addEventListener('pointerdown', (e) => e.stopPropagation()); + inputWrap.addEventListener('mousedown', (e) => e.stopPropagation()); + inputWrap.addEventListener('click', (e) => e.stopPropagation()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; @@ -1984,6 +2129,12 @@ voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; input.addEventListener('input', () => syncInsertCreateButton()); + input.addEventListener('pointerdown', (e) => e.stopPropagation()); + input.addEventListener('mousedown', (e) => e.stopPropagation()); + input.addEventListener('click', (e) => { + e.stopPropagation(); + try { input.focus({ preventScroll: true }); } catch { input.focus(); } + }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); @@ -2049,6 +2200,7 @@ }); create.addEventListener('mouseleave', hideInsertCreateTooltip); create.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); if (controlsLocked) { showManualApplyBusyToast(); return; } if (!isInsertCreateEnabled(create)) return; @@ -2060,7 +2212,7 @@ return row; } - // --- Generating row --- + // Generating row function buildGeneratingRow() { const row = el('div', { @@ -2086,19 +2238,24 @@ }); // Variants currently arrive atomically in a single file edit, so a // per-variant counter would lie. Say what's true. - status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + expectedVariants + ' variants...' - : 'Done'; + status.textContent = recoveryWaitingForAnchor + ? 'Variants ready. Reveal the selected element to resume.' + : (arrivedVariants < expectedVariants + ? 'Generating ' + expectedVariants + ' variants...' + : 'Done'); row.appendChild(status); return row; } - // --- Cycling row --- + // Cycling row const TUNE_ICON_SVG = ''; function buildCyclingRow() { + if (!ensureCyclingRenderable('build-cycling-row')) { + return el('div', { display: 'none' }); + } const row = el('div', { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 2px', @@ -2106,6 +2263,7 @@ // Prev const prev = navBtn('\u2190'); + prev.id = PREFIX + '-variant-prev'; prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); }); if (visibleVariant <= 1) prev.style.opacity = '0.3'; row.appendChild(prev); @@ -2118,11 +2276,13 @@ fontFamily: MONO, fontSize: '11px', fontWeight: '500', color: BP.textDim, minWidth: '24px', textAlign: 'center', }); + counter.id = PREFIX + '-variant-counter'; counter.textContent = visibleVariant + '/' + arrivedVariants; row.appendChild(counter); // Next const next = navBtn('\u2192'); + next.id = PREFIX + '-variant-next'; next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); }); if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3'; row.appendChild(next); @@ -2208,9 +2368,9 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders - // --- Saving row (waiting for agent to process accept/discard) --- + // Saving row (waiting for agent to process accept/discard) function buildSavingRow() { const row = el('div', { @@ -2235,7 +2395,7 @@ return row; } - // --- Confirmed row (green success, auto-dismisses) --- + // Confirmed row (green success, auto-dismisses) function buildConfirmedRow() { const row = el('div', { @@ -2256,7 +2416,7 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders function buildDots(clickable) { const container = el('div', { @@ -2290,10 +2450,7 @@ const idx = i; dot.addEventListener('click', (e) => { e.stopPropagation(); - visibleVariant = idx; - showVariantInDOM(currentSessionId, idx); - updateSelectedElement(); - updateBarContent('cycling'); + selectVariant(idx, 'variant_changed'); }); } container.appendChild(dot); @@ -2323,13 +2480,14 @@ function el(tag, styles) { const e = document.createElement(tag); + if (String(tag).toLowerCase() === 'button') e.type = 'button'; if (styles) Object.assign(e.style, styles); return e; } - // --------------------------------------------------------------------------- + // // Action picker popover - // --------------------------------------------------------------------------- + // function initActionPicker() { const P = barPaletteForTheme(detectPageTheme()); @@ -2384,16 +2542,20 @@ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent'; }); chip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + const prompt = uiGetById(PREFIX + '-input')?.value || ''; selectedAction = action.value; hideActionPicker(); updateBarContent('configure'); + const input = uiGetById(PREFIX + '-input'); + if (input && prompt) input.value = prompt; }); grid.appendChild(chip); }); pickerEl.appendChild(grid); - document.body.appendChild(pickerEl); + uiAppend(pickerEl); defangOutsideHandlers(pickerEl); // Cache the palette on the picker so toggleActionPicker's state refresh @@ -2433,7 +2595,33 @@ setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180); } - // --------------------------------------------------------------------------- + function ensureCyclingRenderable(reason) { + if (arrivedVariants > 0) { + if (visibleVariant < 1 || visibleVariant > arrivedVariants) visibleVariant = 1; + return true; + } + recoverEmptyCycling(reason); + return false; + } + + function recoverEmptyCycling(reason) { + if (recoveringEmptyCycling) return; + recoveringEmptyCycling = true; + try { + console.warn('[impeccable] Refusing to render empty variant cycling state:', reason); + const message = 'No variants were mounted. Please try again.'; + if (svelteComponentSession?.sessionId === currentSessionId) { + abortSvelteComponentInjection(currentSessionId, message); + return; + } + cleanup(); + showToast(message, 5000); + } finally { + recoveringEmptyCycling = false; + } + } + + // // Params panel (per-variant coarse controls) // // Variants may declare a parameter manifest via a JSON attribute on the @@ -2446,13 +2634,13 @@ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped // CSS can respond instantly without regeneration: // - // range / numeric toggle → CSS var (`--p-`) used via var(--p-foo, N) + // range / numeric toggle -> CSS custom property used by variant styles // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."] // // On variant switch, values reset to that variant's declared defaults. // On accept, current values are sent in the event payload so the agent // can bake them into the source-file write. - // --------------------------------------------------------------------------- + // let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide) let paramsPanelInner = null; // translating content (carries bg, padding, knobs) @@ -2507,7 +2695,7 @@ }); paramsPanelEl.appendChild(paramsPanelBody); - document.body.appendChild(paramsPanelEl); + uiAppend(paramsPanelEl); // Don't override pointer-events: the panel toggles between 'none' (closed, // click-through) and 'auto' (open) on its own. Just silence the host's // outside-interaction listeners while the panel is open. @@ -2516,14 +2704,40 @@ } + function getMountedSvelteComponentAnchor(session = svelteComponentSession) { + const el = session?.mountTargetEl?.firstElementChild || null; + if (!el || !document.body.contains(el)) return null; + return rectIsUsableAnchor(el.getBoundingClientRect()) ? el : null; + } + + function resolveSvelteComponentAnchor(session = svelteComponentSession) { + return getMountedSvelteComponentAnchor(session) + || session?.swapAnchor + || null; + } + function getVisibleVariantEl() { if (!currentSessionId) return null; + if (svelteComponentSession?.sessionId === currentSessionId) { + return resolveSvelteComponentAnchor() + || svelteComponentSession.wrapperEl + || null; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } function parseVariantParams(variantEl) { + // Svelte component variants can't carry a `data-impeccable-params` attribute: + // the compiler reads `{` inside attribute values as expression delimiters, so + // JSON-with-braces breaks the build. For that path the params live in a sidecar + // params.json keyed by variant number, loaded into the session at mount time. + if (svelteComponentSession?.sessionId === currentSessionId) { + const byVariant = svelteComponentSession.paramsByVariant || {}; + const params = byVariant[String(visibleVariant)] || byVariant[visibleVariant]; + return Array.isArray(params) ? params : []; + } if (!variantEl) return []; const raw = variantEl.getAttribute('data-impeccable-params'); if (!raw) return []; @@ -2685,11 +2899,11 @@ } } - // --------------------------------------------------------------------------- + // // Inline text editing - makes pure-text descendants of the picked element // directly contenteditable. Save stages copy edits in the live buffer; the // Apply copy edits dock later asks the AI to apply the staged batch. - // --------------------------------------------------------------------------- + // let inlineEditRows = []; let inlineEditDrafts = new Map(); @@ -2803,7 +3017,7 @@ function disableInlineEdit(opts = {}) { for (const row of inlineEditRows) { - if (document.activeElement === row.el) row.el.blur(); + if (activeElementDeep() === row.el) row.el.blur(); row.el.removeAttribute('contenteditable'); delete row.el.dataset.impeccableEditable; delete row.el.dataset.impeccableOriginalText; @@ -3133,7 +3347,7 @@ if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) { showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); } else { - showToast('Save failed: retry or cancel', 4000); + showToast('Save failed - retry or cancel', 4000); } } } @@ -3181,11 +3395,11 @@ } function ensureSpinKeyframes() { - if (document.getElementById(PREFIX + '-keyframes')) return; + if (uiGetById(PREFIX + '-keyframes')) return; const style = document.createElement('style'); style.id = PREFIX + '-keyframes'; style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }'; - document.head.appendChild(style); + uiAppendStyle(style); } function pendingApplyLabel(count) { @@ -3318,10 +3532,10 @@ closeTunePopover(); } if (barEl && barEl.style.display !== 'none' && state === 'CONFIGURING') { - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value : ''; updateBarContent('configure'); - const nextInput = document.getElementById(PREFIX + '-input'); + const nextInput = uiGetById(PREFIX + '-input'); if (nextInput) nextInput.value = prompt; } if (editBadgeEl && editBadgeEl.style.display !== 'none') { @@ -3455,19 +3669,19 @@ updatePendingCounter(remaining); if (result.failed && result.failed.length > 0) { console.warn('[impeccable] some copy edits failed:', result.failed); - showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed - see console', 5000); } else { const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0); if (n > 0) { showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); } else { console.warn('[impeccable] apply returned no verified edits:', result); - showToast('No edits applied, see console', 4000); + showToast('No edits applied - see console', 4000); } } } catch (err) { console.error('[impeccable] commit failed:', err); - showToast('Apply failed, see console', 4000); + showToast('Apply failed - see console', 4000); } finally { if (waitForSseCompletion) return; const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0; @@ -3497,7 +3711,7 @@ } } catch (err) { console.error('[impeccable] discard failed:', err); - showToast('Discard failed, see console', 4000); + showToast('Discard failed - see console', 4000); } } @@ -3645,7 +3859,7 @@ const failedCount = numberOrNull(msg.failedCount) || 0; const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0; if (failedCount > 0) { - showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000); + showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed - see console', 5000); } else if (appliedCount > 0) { showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500); } @@ -3799,9 +4013,164 @@ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); } - // --------------------------------------------------------------------------- + // // Edit content badge - floating button at element top-right to enter EDITING mode - // --------------------------------------------------------------------------- + // + + function usesShadowChromeRoot() { + const root = liveUiRoot(); + return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; + } + + function setImportantStyle(el, name, value) { + el.style.setProperty(name, value, 'important'); + } + + function initEditBadgeHitProxies() { + if (!usesShadowChromeRoot() || editBadgeProxyRoot) return; + editBadgeProxyRoot = document.createElement('div'); + editBadgeProxyRoot.id = PREFIX + '-edit-badge-hit-proxies'; + editBadgeProxyRoot.setAttribute('aria-hidden', 'true'); + const styles = { + all: 'initial', + position: 'fixed', + inset: '0', + width: '100vw', + height: '100vh', + zIndex: String(Z.toast + 1), + pointerEvents: 'none', + background: 'transparent', + overflow: 'visible', + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + document.body.appendChild(editBadgeProxyRoot); + } + + function styleEditBadgeProxy(proxy, target) { + const rect = target.getBoundingClientRect(); + const cursor = getComputedStyle(target).cursor || 'pointer'; + const styles = { + all: 'initial', + position: 'fixed', + left: rect.left + 'px', + top: rect.top + 'px', + width: rect.width + 'px', + height: rect.height + 'px', + margin: '0', + padding: '0', + border: '0', + borderRadius: '0', + background: 'transparent', + color: 'transparent', + opacity: '0.001', + pointerEvents: 'auto', + cursor, + zIndex: String(Z.toast + 2), + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(proxy, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + } + + function proxyMouseEvent(type, source, target) { + let event; + try { + event = new MouseEvent(type, { + bubbles: type !== 'mouseenter' && type !== 'mouseleave', + cancelable: true, + composed: true, + clientX: source.clientX, + clientY: source.clientY, + screenX: source.screenX, + screenY: source.screenY, + button: source.button || 0, + buttons: source.buttons || 0, + ctrlKey: source.ctrlKey, + metaKey: source.metaKey, + shiftKey: source.shiftKey, + altKey: source.altKey, + }); + target.dispatchEvent(event); + } catch {} + } + + function bindEditBadgeProxy(proxy, target) { + const stop = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + proxy.addEventListener('mouseenter', (event) => { + stop(event); + proxyMouseEvent('mouseenter', event, target); + proxyMouseEvent('mouseover', event, target); + }); + proxy.addEventListener('mouseleave', (event) => { + stop(event); + proxyMouseEvent('mouseleave', event, target); + proxyMouseEvent('mouseout', event, target); + }); + proxy.addEventListener('mousedown', (event) => { + stop(event); + target.focus?.({ preventScroll: true }); + proxyMouseEvent('mousedown', event, target); + }); + proxy.addEventListener('mouseup', (event) => { + stop(event); + proxyMouseEvent('mouseup', event, target); + }); + proxy.addEventListener('click', (event) => { + stop(event); + target.click(); + syncEditBadgeHitProxies(); + }); + } + + function editBadgeProxyTargets() { + if (!usesShadowChromeRoot() || !editBadgeEl || editBadgeEl.style.display === 'none') return []; + return [...editBadgeEl.querySelectorAll('button')].filter((target) => { + if (target.disabled) return false; + const rect = target.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return false; + const style = getComputedStyle(target); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); + } + + function syncEditBadgeHitProxies() { + if (!usesShadowChromeRoot()) { + if (editBadgeProxyRoot) editBadgeProxyRoot.remove(); + editBadgeProxyRoot = null; + editBadgeProxyByTarget = new Map(); + return; + } + initEditBadgeHitProxies(); + if (!editBadgeProxyRoot) return; + const targets = editBadgeProxyTargets(); + const active = new Set(targets); + for (const [target, proxy] of editBadgeProxyByTarget) { + if (!active.has(target) || !target.isConnected) { + proxy.remove(); + editBadgeProxyByTarget.delete(target); + } + } + for (const target of targets) { + let proxy = editBadgeProxyByTarget.get(target); + if (!proxy) { + proxy = document.createElement('button'); + proxy.type = 'button'; + proxy.tabIndex = -1; + proxy.dataset.impeccableEditBadgeProxy = 'true'; + proxy.setAttribute('aria-hidden', 'true'); + bindEditBadgeProxy(proxy, target); + editBadgeProxyRoot.appendChild(proxy); + editBadgeProxyByTarget.set(target, proxy); + } + proxy.title = target.title || target.textContent || 'Edit copy'; + styleEditBadgeProxy(proxy, target); + } + } function initEditBadge() { editBadgeEl = document.createElement('div'); @@ -3813,10 +4182,11 @@ display: 'none', userSelect: 'none', }); - document.body.appendChild(editBadgeEl); + uiAppend(editBadgeEl); + initEditBadgeHitProxies(); // Remove focus rings on edit badge buttons + contenteditable elements - if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + if (!uiGetById(PREFIX + '-edit-badge-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-edit-badge-focus-style'; s.textContent = @@ -3826,21 +4196,26 @@ '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; - document.head.appendChild(s); + uiAppendStyle(s); } } function positionEditBadge() { - if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') { + syncEditBadgeHitProxies(); + return; + } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { if (editBadgeEl) editBadgeEl.style.display = 'none'; + syncEditBadgeHitProxies(); return; } editBadgeEl.style.display = 'flex'; @@ -4047,7 +4422,7 @@ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN; } // Re-render the bar so the Tune chip picks up the active styling. - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } function closeTunePopover() { @@ -4055,13 +4430,13 @@ hideParamsPanel(); if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT; if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') { - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } } - // --------------------------------------------------------------------------- + // // Variant cycling in DOM - // --------------------------------------------------------------------------- + // function isVariantShown(el) { if (!el) return false; @@ -4081,9 +4456,42 @@ } } - function showVariantInDOM(sessionId, num) { + function scheduleCyclingBarSync(sessionId, variantNum) { + requestAnimationFrame(() => { + if (state !== 'CYCLING') return; + if (currentSessionId !== sessionId) return; + if (visibleVariant !== variantNum) return; + showOrUpdateCyclingBar(); + syncCyclingControls(); + positionBar(); + }); + } + + function syncCyclingControls() { + const shown = svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0 + ? svelteComponentSession.mountedVariant + : visibleVariant; + const counter = uiGetById(PREFIX + '-variant-counter'); + if (counter && arrivedVariants > 0) counter.textContent = shown + '/' + arrivedVariants; + const prev = uiGetById(PREFIX + '-variant-prev'); + const next = uiGetById(PREFIX + '-variant-next'); + if (prev) prev.style.opacity = shown <= 1 ? '0.3' : '1'; + if (next) next.style.opacity = shown >= arrivedVariants ? '0.3' : '1'; + if (currentSessionId && state === 'CYCLING') saveSession(); + } + + async function showVariantInDOM(sessionId, num) { + if (svelteComponentSession?.sessionId === sessionId) { + visibleVariant = num; + const mounted = await mountSvelteComponentVariant(num); + if (!mounted) return false; + updateSelectedElement(); + refreshParamsPanel(); + scheduleCyclingBarSync(sessionId, num); + return true; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); - if (!wrapper) return; + if (!wrapper) return false; for (const child of wrapper.children) { const v = child.dataset ? child.dataset.impeccableVariant : null; if (!v) continue; @@ -4093,6 +4501,378 @@ // CYCLING yet, the subsequent CYCLING transition triggers its own // refresh) and every cycle step. refreshParamsPanel(); + return true; + } + + function isSvelteComponentManifestPath(filePath) { + return String(filePath || '').endsWith('manifest.json'); + } + + function parseOriginalMarkupElement(originalMarkup) { + const parser = new DOMParser(); + const doc = parser.parseFromString('
' + originalMarkup + '
', 'text/html'); + return doc.getElementById('impeccable-anchor')?.firstElementChild || null; + } + + function findLiveElementForOriginalMarkup(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const tag = origContent.tagName.toLowerCase(); + const cls = origContent.className; + let liveEl = null; + if (origContent.id) { + liveEl = document.getElementById(origContent.id); + } else if (cls) { + const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + for (const c of candidates) { + if (c.className === cls && !own(c)) { liveEl = c; break; } + } + if (!liveEl) { + const expectedClasses = String(cls).split(/\s+/).filter(Boolean); + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } + } + } + return liveEl; + } + + function isSvelteInsertManifest(manifest) { + return manifest?.previewMode === 'svelte-component' && manifest?.mode === 'insert'; + } + + function findLiveElementForSvelteManifest(manifest) { + if (isSvelteInsertManifest(manifest)) { + const anchor = findInsertAnchorInDom(); + if (anchor?.parentElement) return anchor; + } + return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function loadSvelteRuntime(runtimeModule) { + const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js'; + const url = new URL(modulePath, location.origin).href; + if (!svelteRuntimePromise) { + svelteRuntimePromise = import(/* @vite-ignore */ url); + } + return svelteRuntimePromise; + } + + // Svelte component variants declare their params in a sidecar params.json under + // componentDir (keyed by variant number), because a `data-impeccable-params` + // attribute with JSON braces can't survive the Svelte compiler. Returns a map of + // { "1": [...params], "2": [...] }; an empty object when the agent declared none. + async function loadSvelteComponentParams(manifest) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir) return {}; + const paramsPath = dir + '/params.json'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(paramsPath); + try { + const res = await fetch(url); + if (!res.ok) return {}; + const parsed = JSON.parse(await res.text()); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out = {}; + for (const [key, value] of Object.entries(parsed)) { + if (Array.isArray(value)) out[String(key)] = value; + } + return out; + } catch { + return {}; + } + } + + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { + const contract = manifest?.propContract || []; + const values = {}; + if (!liveEl || contract.length === 0) return values; + const sourceOriginal = parseOriginalMarkupElement(manifest.originalMarkup || ''); + if (!sourceOriginal) return values; + const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl); + for (const entry of contract) { + const token = '{' + entry.expr + '}'; + values[entry.prop] = map.get(token) || ''; + } + return values; + } + + async function mountSvelteComponentVariant(variantNum) { + if (!svelteComponentSession || !variantNum) return false; + const { manifest, mountTargetEl, sessionId } = svelteComponentSession; + try { + const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement; + svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null; + const runtime = await loadSvelteRuntime(manifest.runtimeModule); + const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte'; + const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now(); + const mod = await import(/* @vite-ignore */ moduleUrl); + const Component = mod.default; + if (svelteComponentSession.mountedInstance && runtime.unmount) { + await runtime.unmount(svelteComponentSession.mountedInstance); + svelteComponentSession.mountedInstance = null; + } + svelteComponentSession.mountedInstance = runtime.mount(Component, { + target: mountTargetEl, + props: { ...svelteComponentSession.propValues }, + intro: false, + }); + svelteComponentSession.mountedVariant = variantNum; + svelteComponentSession.runtime = runtime; + if (state === 'CYCLING') syncCyclingControls(); + const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (nextAnchor) { + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(nextAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = nextAnchor; + } else { + requestAnimationFrame(() => { + if (svelteComponentSession?.sessionId !== sessionId) return; + const settledAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!settledAnchor) return; + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(settledAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = settledAnchor; + }); + } + return true; + } catch (err) { + if (svelteComponentSession?.sessionId === sessionId) { + svelteComponentSession.swapAnchor = null; + } + console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err); + return false; + } + } + + function teardownSvelteComponentSession(restoreOriginal) { + if (!svelteComponentSession) return; + const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + if (restoreOriginal && detachedOriginal && wrapperEl?.parentElement) { + wrapperEl.parentElement.replaceChild(detachedOriginal, wrapperEl); + } else if (wrapperEl?.parentElement) { + wrapperEl.remove(); + } + svelteComponentSession = null; + svelteRuntimePromise = null; + } + + function applyOriginalAttrsToSvelteAnchor(el, originalMarkup) { + if (!el || !originalMarkup) return; + const original = parseOriginalMarkupElement(originalMarkup); + if (!original || original.tagName !== el.tagName) return; + for (const attr of original.attributes) { + if (attr.name === 'class') { + for (const className of attr.value.split(/\s+/).filter(Boolean)) { + el.classList.add(className); + } + } else if (!el.hasAttribute(attr.name)) { + el.setAttribute(attr.name, attr.value); + } + } + } + + function commitAcceptedSvelteComponentToDom(sessionId) { + if (!svelteComponentSession || svelteComponentSession.sessionId !== sessionId) return false; + const { wrapperEl, runtime, mountedInstance, manifest } = svelteComponentSession; + const anchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!anchor || !wrapperEl?.parentElement) return false; + const committed = anchor.cloneNode(true); + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(committed, manifest.originalMarkup || ''); + } + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + wrapperEl.parentElement.replaceChild(committed, wrapperEl); + svelteComponentSession = null; + svelteRuntimePromise = null; + selectedElement = committed; + return true; + } + + async function injectSvelteComponentsFromManifest(manifestPath, sessionId) { + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(manifestPath); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + const manifest = JSON.parse(await res.text()); + if (manifest.id !== sessionId) return; + + const paramsByVariant = await loadSvelteComponentParams(manifest); + currentSessionId = sessionId; + expectedVariants = Number(manifest.count) || expectedVariants || 1; + rememberSessionFileMeta({ + sourceFile: manifest.sourceFile, + previewFile: manifestPath, + previewMode: 'svelte-component', + }); + if (state !== 'CYCLING') state = 'GENERATING'; + + const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { + recoveryWaitingForAnchor = false; + svelteComponentSession.paramsByVariant = paramsByVariant; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; + await mountSvelteComponentVariant(visibleVariant || 1); + state = 'CYCLING'; + showOrUpdateCyclingBar(); + saveSession(); + return; + } + + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) { + console.warn('[impeccable] Could not find original element in live DOM.'); + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants + ? visibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + saveSession(); + queueCheckpoint('svelte_component_anchor_missing'); + waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return; + } + + const wrapper = document.createElement('div'); + wrapper.dataset.impeccableVariants = sessionId; + wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1); + wrapper.dataset.impeccablePreview = 'svelte-component'; + wrapper.style.display = 'contents'; + + const mountTarget = document.createElement('div'); + mountTarget.dataset.impeccableComponentMount = sessionId; + mountTarget.style.display = 'contents'; + wrapper.appendChild(mountTarget); + + const insertMode = isSvelteInsertManifest(manifest); + const detachedOriginal = insertMode ? null : liveEl; + if (insertMode) { + removeInsertPlaceholderDom(); + if (manifest.position === 'before') liveEl.parentElement.insertBefore(wrapper, liveEl); + else liveEl.parentElement.insertBefore(wrapper, liveEl.nextSibling); + } else { + liveEl.parentElement.replaceChild(wrapper, liveEl); + } + + svelteComponentSession = { + sessionId, + manifest, + insertMode, + wrapperEl: wrapper, + mountTargetEl: mountTarget, + detachedOriginal, + mountedInstance: null, + mountedVariant: 0, + runtime: null, + propValues: buildSveltePropValuesFromLiveElement(detachedOriginal, manifest), + paramsByVariant, + }; + if (pendingSvelteComponentRetryObserver) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + } + recoveryWaitingForAnchor = false; + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + + const mounted = await mountSvelteComponentVariant(visibleVariant); + if (!mounted) { + // The compiled component threw (e.g. a Svelte compile error in the + // variant file). Don't strand the bar in an empty CYCLING state; restore + // the original element and reset to PICKING so the user can retry. + abortSvelteComponentInjection(sessionId, 'A variant failed to compile. Fix the component and re-run.'); + return; + } + + selectedElement = mountTarget.firstElementChild || mountTarget; + state = 'CYCLING'; + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.'); + } catch (err) { + console.error('[impeccable] Failed to mount Svelte component variants:', err); + abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.'); + } + } + + function waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }) { + if (pendingSvelteComponentRetryObserver) pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = new MutationObserver(() => { + if (svelteComponentSession?.sessionId === sessionId) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + return; + } + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) return; + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + injectSvelteComponentsFromManifest(manifestPath, sessionId); + }); + pendingSvelteComponentRetryObserver.observe(document.body, { childList: true, subtree: true }); + } + + // Reset cleanly when a Svelte component session can't mount: tear the wrapper + // down (restoring the original element), clear persisted session state, and + // return the bar to PICKING. Avoids the stuck 0/0 CYCLING bar. + function abortSvelteComponentInjection(sessionId, message) { + try { + if (svelteComponentSession?.sessionId === sessionId) { + teardownSvelteComponentSession(true); + } else { + const orphan = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (orphan) orphan.remove(); + } + } catch (err) { + console.warn('[impeccable] Svelte component abort cleanup failed:', err); + } + hideShaderOverlay(); + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + stopScrollLock(); + clearSession(); + clearHandled(); + resetSessionFileMeta(); + currentSessionId = null; + expectedVariants = 0; + arrivedVariants = 0; + visibleVariant = 0; + selectedElement = null; + state = 'PICKING'; + hideBar(); + if (message) showToast(message, 5000); } /** @@ -4101,6 +4881,11 @@ * This works even when the dev server caches HTML (Bun, static servers). */ function injectVariantsFromSource(filePath, sessionId) { + if (isSvelteComponentManifestPath(filePath)) { + injectSvelteComponentsFromManifest(filePath, sessionId); + return; + } + rememberSessionFileMeta({ file: filePath }); const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) @@ -4119,7 +4904,7 @@ const doc = parser.parseFromString(block, 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { - console.error('[impeccable] Variant wrapper not found in source file.'); + console.warn('[impeccable] Variant wrapper not found in source file.'); return; } @@ -4134,31 +4919,31 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; - if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - } - + const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); if (!liveEl) { - console.error('[impeccable] Could not find original element in live DOM.'); + console.warn('[impeccable] Could not find original element in live DOM.'); + selectedElement = document.body; + recoveryWaitingForAnchor = true; + state = 'GENERATING'; + showBar('generating'); + saveSession(); + showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } + recoveryWaitingForAnchor = false; // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); arrivedVariants = variants.length; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + recoverEmptyCycling('source-fallback-empty'); + return; + } const saved = loadSession(); const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants @@ -4170,8 +4955,9 @@ selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4184,21 +4970,129 @@ }); } - function cycleVariant(dir) { + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { + const map = new Map(); + if (!sourceOriginal || !liveOriginal) return map; + + const sourceNodes = collectTextNodes(sourceOriginal) + .filter((node) => /\{[^{}]+\}/.test(node.nodeValue || '')); + const liveTexts = collectTextNodes(liveOriginal) + .map((node) => normalizePreviewText(node.nodeValue || '')) + .filter(Boolean); + let liveIndex = 0; + + for (const sourceNode of sourceNodes) { + const sourceText = sourceNode.nodeValue || ''; + const tokens = sourceText.match(/\{[^{}]+\}/g) || []; + if (tokens.length === 0) continue; + + const liveText = liveTexts[liveIndex++] || ''; + if (!liveText) continue; + + if (tokens.length === 1) { + const token = tokens[0]; + const normalizedSource = normalizePreviewText(sourceText); + if (normalizedSource === token) { + map.set(token, liveText); + continue; + } + + const match = liveText.match(expressionTextMatcher(sourceText, [token])); + if (match && match[1]) map.set(token, match[1].trim()); + continue; + } + + if (normalizePreviewText(sourceText) === tokens.join(' ')) { + for (const token of tokens) { + const tokenLiveText = liveTexts[liveIndex - 1] || ''; + if (tokenLiveText) map.set(token, tokenLiveText); + } + } + } + + return map; + } + + function expressionTextMatcher(sourceText, tokens) { + let pattern = '^'; + let cursor = 0; + for (const token of tokens) { + const index = sourceText.indexOf(token, cursor); + if (index === -1) continue; + pattern += escapeRegExp(sourceText.slice(cursor, index)).replace(/\s+/g, '\\s*'); + pattern += '(.*?)'; + cursor = index + token.length; + } + pattern += escapeRegExp(sourceText.slice(cursor)).replace(/\s+/g, '\\s*') + '$'; + return new RegExp(pattern); + } + + function collectTextNodes(root) { + if (!root) return []; + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + nodes.push(node); + node = walker.nextNode(); + } + return nodes; + } + + function normalizePreviewText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + async function selectVariant(next, checkpointReason) { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - const next = visibleVariant + dir; + if (variantSelectionInFlight) return; if (next < 1 || next > arrivedVariants) return; - visibleVariant = next; - showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself - updateSelectedElement(); - updateBarContent('cycling'); - positionBar(); - saveSession(); - queueCheckpoint('variant_changed'); + if (next === visibleVariant) return; + + const previous = visibleVariant; + variantSelectionInFlight = true; + const selectionPromise = (async () => { + visibleVariant = next; + showOrUpdateCyclingBar(); + saveSession(); + const shown = await showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself + if (!shown) { + visibleVariant = previous; + await showVariantInDOM(currentSessionId, previous); + showOrUpdateCyclingBar(); + saveSession(); + return; + } + updateSelectedElement(); + showOrUpdateCyclingBar(); + positionBar(); + saveSession(); + if (checkpointReason) queueCheckpoint(checkpointReason); + })(); + variantSelectionPromise = selectionPromise; + try { + await selectionPromise; + } finally { + if (variantSelectionPromise === selectionPromise) variantSelectionPromise = null; + variantSelectionInFlight = false; + } + } + + function cycleVariant(dir) { + selectVariant(visibleVariant + dir, 'variant_changed'); } function updateSelectedElement() { if (!currentSessionId) return; + if (svelteComponentSession?.sessionId === currentSessionId) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; + return; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); @@ -4206,6 +5100,9 @@ } function readVisibleVariantFromDOM(sessionId) { + if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { + return svelteComponentSession.mountedVariant; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -4341,9 +5238,9 @@ // scrollY that the next resume needs to read. } - // --------------------------------------------------------------------------- + // // MutationObserver for progressive variant reveal - // --------------------------------------------------------------------------- + // function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -4426,10 +5323,11 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); updateSelectedElement(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4445,9 +5343,9 @@ return obs; } - // --------------------------------------------------------------------------- + // // Bar scroll tracking - // --------------------------------------------------------------------------- + // function startScrollTracking() { function tick() { @@ -4483,10 +5381,10 @@ if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; } } - // --------------------------------------------------------------------------- + // // SSE (server→browser) + fetch POST (browser→server) // Zero-dependency replacement for WebSocket. - // --------------------------------------------------------------------------- + // let evtSource = null; let sseRetries = 0; @@ -4509,6 +5407,7 @@ console.log('[impeccable] Live mode connected.'); syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); + restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; syncPageChatFocus('sse-connected'); break; @@ -4531,11 +5430,12 @@ break; case 'done': if (maybeCompleteSteer(msg)) break; + rememberSessionFileMeta(msg); // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); } @@ -4557,7 +5457,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; if (state !== 'GENERATING') return; showToast( - "Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.", + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", 15000, ); }, 2000); @@ -4571,12 +5471,18 @@ // the final complete event. Keep the browser in its recoverable // saving state while the source cleanup is still in flight. break; + case 'discarded': + if (msg.id && msg.id === currentSessionId) { + markSessionHandled(); + cleanup(); + } + break; case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; state = 'CYCLING'; updateBarContent('cycling'); - showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000); + showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; } if (maybeCompleteSteer(msg)) break; @@ -4656,6 +5562,9 @@ expectedVariants, arrivedVariants, visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, paramValues: { ...paramsCurrentValues }, }; } @@ -4665,6 +5574,20 @@ return sendEvent(checkpointPayload(reason)).catch(() => null); } + function sendSteerCheckpoint(id, reason, extra) { + if (!id) return Promise.resolve(null); + return sendEvent({ + type: 'checkpoint', + id, + revision: sessionState.nextCheckpointRevision(), + owner: browserOwner, + phase: 'steer', + reason, + pageUrl: location.pathname, + ...(extra || {}), + }).catch(() => null); + } + function queueCheckpoint(reason) { if (!currentSessionId) return; if (checkpointTimer) clearTimeout(checkpointTimer); @@ -4674,9 +5597,9 @@ }, 120); } - // --------------------------------------------------------------------------- + // // Event handlers - // --------------------------------------------------------------------------- + // function handleMouseMove(e) { if (pendingApplyInFlight) return; @@ -4860,7 +5783,7 @@ // // DISABLED: quick-Go workflows pay an extra harness round trip because // prefetch + generate arrive as two events instead of one. Re-enable with - // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to + // a browser-side debounce (~800-1000ms, cancelled on Go) if we want to // resurrect this. Server validator and skill dispatch remain in place so // flipping this flag is the only change needed. const PREFETCH_ENABLED = false; @@ -4876,6 +5799,14 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + const deepActive = activeElementDeep(); + if ( + deepActive + && own(deepActive) + && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + ) { + return; + } // While a contenteditable text-leaf is focused, let the browser handle // all keys except Escape. Escape cancels the current edit (restores // original text) and blurs without saving, staying in CONFIGURING. @@ -4982,7 +5913,7 @@ if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!selectedElement || state !== 'CONFIGURING') return; stopVoice({ suppressSubmit: true }); - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; // Commit any pending pin edit BEFORE we snapshot annotations. @@ -4996,6 +5927,7 @@ expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); // Flip to GENERATING immediately so the bar morphs without waiting on // capture + upload. The event is emitted from captureAndEmit() once the @@ -5054,7 +5986,7 @@ function handleInsertCreate() { if (!placeholderElement || !insertAnchorElement || state !== 'CONFIGURING' || configureKind !== 'insert') return; - const input = document.getElementById(PREFIX + '-insert-input'); + const input = uiGetById(PREFIX + '-insert-input'); const prompt = input ? input.value.trim() : ''; if (annotEditing) finalizeEditingPin(); const snapshot = { @@ -5064,10 +5996,12 @@ if (!canCreateInsert({ prompt, comments: snapshot.comments, strokes: snapshot.strokes })) return; stopVoice({ suppressSubmit: true }); + pendingAcceptedSession = null; currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); selectedElement = placeholderElement; insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement); @@ -5107,9 +6041,9 @@ captureAndEmit(elForCapture, basePayload, snapshot, captureRect); } - // --------------------------------------------------------------------------- + // // Screenshot capture + upload - // --------------------------------------------------------------------------- + // let msLoadPromise = null; function loadModernScreenshot() { @@ -5120,7 +6054,7 @@ s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; s.onload = () => resolve(window.modernScreenshot); s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; - document.head.appendChild(s); + uiAppendStyle(s); }); return msLoadPromise; } @@ -5235,11 +6169,113 @@ return '#ffffff'; } + function captureChromeNodes() { + const nodes = []; + const add = (node) => { + if (!node || node === document.body || nodes.includes(node)) return; + nodes.push(node); + }; + add(document.getElementById(PREFIX + '-root')); + [ + PREFIX + '-highlight', + PREFIX + '-tooltip', + PREFIX + '-bar', + PREFIX + '-picker', + PREFIX + '-params-panel', + PREFIX + '-insert-line', + PREFIX + '-insert-placeholder', + PREFIX + '-insert-create-tooltip', + PREFIX + '-annot', + PREFIX + '-design-host', + PREFIX + '-toast', + PREFIX + '-shader', + ].forEach((id) => add(uiGetById(id))); + return nodes; + } + + async function hideCaptureChromeForShaderProxy(fn) { + const saved = captureChromeNodes().map((node) => ({ + node, + visibility: node.style.visibility, + priority: node.style.getPropertyPriority('visibility'), + })); + for (const { node } of saved) { + node.style.setProperty('visibility', 'hidden', 'important'); + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + try { + return await fn(); + } finally { + for (const { node, visibility, priority } of saved) { + node.style.setProperty('visibility', visibility, priority); + } + } + } + + function shouldUseAncestorCropShaderProxy(el) { + // TODO: Enable this proxy for React/Vue/etc. adapters once their live + // preview mounts are covered by the same shader regression checks. + const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase(); + if (adapter === 'svelte' || adapter === 'sveltekit') return true; + if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true; + const wrapper = el?.closest?.('[data-impeccable-variants]'); + return wrapper?.dataset?.impeccablePreview === 'svelte-component'; + } + + function paintsShaderProxySurface(node) { + const s = getComputedStyle(node); + return !isTransparentColor(s.backgroundColor) + || (s.backgroundImage && s.backgroundImage !== 'none') + || paintsBackdrop(node); + } + + function findShaderProxyCaptureRoot(el) { + const doc = el.ownerDocument || document; + const er = el.getBoundingClientRect(); + let node = el.parentElement; + while (node && node !== doc.documentElement) { + const nr = node.getBoundingClientRect(); + const containsElement = + nr.width > 0 && nr.height > 0 && + nr.left <= er.left + 0.5 && + nr.top <= er.top + 0.5 && + nr.right >= er.right - 0.5 && + nr.bottom >= er.bottom - 0.5; + if (containsElement && paintsShaderProxySurface(node)) return node; + node = node.parentElement; + } + return null; + } + // Capture the element (with current annotations baked in) and return // { blob, paper }: the PNG Blob, plus the representative backdrop tone for the // shader's halftone ground (so capture, upload, and shader all agree on what // sits behind the element). Shared between the Go flow (uploads the blob) and // the shader-resume path. + async function captureElementFromRenderedAncestor(ms, el, opts) { + const doc = el.ownerDocument || document; + const captureRoot = findShaderProxyCaptureRoot(el); + if (!captureRoot) throw new Error('No painted ancestor for Svelte shader proxy'); + const rootCanvas = await ms.domToCanvas(captureRoot, opts); + const S = opts.scale; + const er = el.getBoundingClientRect(); + const rr = captureRoot.getBoundingClientRect(); + const sx = (er.left - rr.left) * S; + const sy = (er.top - rr.top) * S; + const sw = er.width * S; + const sh = er.height * S; + if (sw <= 0 || sh <= 0) throw new Error('Selected element has no visible capture rect'); + const crop = doc.createElement('canvas'); + crop.width = Math.max(1, Math.round(sw)); + crop.height = Math.max(1, Math.round(sh)); + const cctx = crop.getContext('2d', { willReadFrequently: true }); + cctx.drawImage(rootCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height); + const paper = dominantRgb01(cctx, crop.width, crop.height) || averageRgb01(cctx, crop.width, crop.height); + const blob = await new Promise((res) => crop.toBlob(res, 'image/png')); + if (!blob) throw new Error('Ancestor crop failed to produce a PNG blob'); + return { blob, paper }; + } + async function captureElementToBlob(el, snapshot, rect) { try { if (document.fonts?.ready) await document.fonts.ready; } catch {} const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); @@ -5261,6 +6297,13 @@ scale: Math.min(window.devicePixelRatio || 1, 2), font: fontCssText ? { cssText: fontCssText } : undefined, }; + if (shouldUseAncestorCropShaderProxy(el)) { + try { + return await hideCaptureChromeForShaderProxy(() => captureElementFromRenderedAncestor(ms, el, opts)); + } catch (err) { + console.warn('[impeccable] Svelte ancestor crop capture failed, falling back to element capture:', err); + } + } const bg = resolveCanvasBackground(el); // Fast path: the element paints its own background, or an opaque ancestor // color was found. modern-screenshot bakes that color; paper matches it. @@ -5342,13 +6385,13 @@ sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); } - // --------------------------------------------------------------------------- + // // Shader overlay - renders the captured screenshot as a WebGL texture and // runs an editorial "ink-wash" fragment shader over it during generation. // A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku // and leaving a soft trail. Makes the wait feel like a letterpress scan // instead of a dead spinner. - // --------------------------------------------------------------------------- + // const SHADER_VS = `attribute vec2 a_position; attribute vec2 a_uv; @@ -5502,6 +6545,31 @@ void main() { return n ? [r / n / 255, g / n / 255, b / n / 255] : SHADER_PAPER_FALLBACK; } + // Pick the most common visible color cluster from a crop. A straight average + // gets pulled by text and icons; the dominant bucket usually represents the + // surface the shader should dissolve into. + function dominantRgb01(ctx, w, h) { + const data = ctx.getImageData(0, 0, w, h).data; + const stride = Math.max(1, Math.floor((w * h) / 6000)); + const buckets = new Map(); + for (let p = 0; p < w * h; p += stride) { + const i = p * 4; + if (data[i + 3] < 16) continue; + const key = (data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4); + const bucket = buckets.get(key) || { count: 0, r: 0, g: 0, b: 0 }; + bucket.count += 1; + bucket.r += data[i]; + bucket.g += data[i + 1]; + bucket.b += data[i + 2]; + buckets.set(key, bucket); + } + let best = null; + for (const bucket of buckets.values()) { + if (!best || bucket.count > best.count) best = bucket; + } + return best ? [best.r / best.count / 255, best.g / best.count / 255, best.b / best.count / 255] : null; + } + // Average the backdrop sampled just OUTSIDE an element's rect within a larger // canvas. The ground tone for the dissolve must be the real backdrop, not the // mean of the element's own crop - averaging the crop folds in the element's @@ -5572,7 +6640,7 @@ void main() { fallback.style.backgroundRepeat = 'no-repeat'; fallback.style.outline = '2px dashed ' + C.brand; fallback.style.outlineOffset = '-2px'; - document.body.appendChild(fallback); + uiAppend(fallback); shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl }; } @@ -5582,16 +6650,19 @@ void main() { const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); + const radius = getComputedStyle(el).borderRadius; canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); Object.assign(canvas.style, { position: 'fixed', top: rect.top + 'px', left: rect.left + 'px', width: rect.width + 'px', height: rect.height + 'px', + borderRadius: radius, + overflow: 'hidden', pointerEvents: 'none', zIndex: Z.bar - 1, }); - document.body.appendChild(canvas); + uiAppend(canvas); const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) || canvas.getContext('experimental-webgl'); @@ -5685,8 +6756,12 @@ void main() { frame(); } - function handleAccept() { + async function handleAccept() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } + if (pendingAcceptedSession || state === 'SAVING') return; + if (variantSelectionPromise) { + try { await variantSelectionPromise; } catch { /* failed selection falls back below */ } + } if (!currentSessionId || arrivedVariants === 0) return; const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId); if (domVisibleVariant > 0) visibleVariant = domVisibleVariant; @@ -5696,30 +6771,39 @@ void main() { variantId: String(visibleVariant), pageUrl: location.pathname, }; + const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } + // The accepted variant is already the only visible child of the wrapper + // (all other variants are display:none). HMR from the source rewrite will + // replace the wrapper imminently. Don't eagerly replaceChild here - React + // reconciliation races with our mutation and throws NotFoundError in Next + // 16 / Turbopack. Schedule a fallback that runs the manual swap only if + // HMR hasn't cleaned up by then (keeps static-server flows working). const acceptedSessionId = currentSessionId; const acceptedVariant = visibleVariant; + const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId + || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - pendingAcceptedSession = { - id: acceptedSessionId, - variant: String(acceptedVariant), - ...acceptedSnapshot, - finalizing: false, - }; state = 'SAVING'; updateBarContent('saving'); + pendingAcceptedSession = { + id: acceptedSessionId, + variant: String(acceptedVariant), + isSvelteComponent: acceptedIsSvelteComponent, + ...acceptedSnapshot, + finalizing: false, + }; + saveSession(); sendEvent(acceptPayload, { throwOnError: true }) - .then(() => { - markSessionHandled(); - }) + .then(() => {}) .catch(() => { - pendingAcceptedSession = null; + if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); } @@ -5733,19 +6817,21 @@ void main() { } if (pending.finalizing) return true; pending.finalizing = true; - + markSessionHandled(); + if (pending.isSvelteComponent) { + commitAcceptedSvelteComponentToDom(pending.id); + } state = 'CONFIRMED'; updateBarContent('confirmed'); + scheduleAcceptCleanup(pending); + return true; + } - // Give framework HMR a short chance to render the now-clean accepted - // source. If it misses the update, unwrap the accepted variant after the - // source-side completion event so the page is not left empty or stale. + function scheduleAcceptCleanup(accepted) { setTimeout(function() { - ensureAcceptedDomClean(pending); + if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted); cleanupAcceptedSession(); }, 1200); - - return true; } function snapshotAcceptedVariantDom(sessionId, variantId) { @@ -5833,6 +6919,7 @@ void main() { stopScrollLock(); clearScrollY(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5841,6 +6928,28 @@ void main() { state = 'PICKING'; } + function commitAcceptedVariantToDom(sessionId, variantId) { + const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapper) return false; + const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); + if (!accepted || !accepted.firstElementChild) return false; + const parent = wrapper.parentElement; + if (!parent) return false; + + const style = wrapper.querySelector('style[data-impeccable-css]'); + if (style && !document.querySelector('style[data-impeccable-accepted-css="' + sessionId + '"]')) { + const promotedStyle = style.cloneNode(true); + promotedStyle.setAttribute('data-impeccable-accepted-css', sessionId); + parent.insertBefore(promotedStyle, wrapper); + } + + const committed = accepted.cloneNode(true); + committed.removeAttribute('hidden'); + committed.style.display = 'contents'; + parent.replaceChild(committed, wrapper); + return true; + } + function handleDiscard() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!currentSessionId) return; @@ -5852,11 +6961,141 @@ void main() { .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000)); } - // --------------------------------------------------------------------------- + // // Session persistence via live-browser-session.js - // --------------------------------------------------------------------------- + // // Survives page reloads, browser close/reopen, HMR, and accidental refreshes. + function normalizeSessionPath(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed.replace(/\\/g, '/') : null; + } + + function resetSessionFileMeta() { + currentSourceFile = null; + currentPreviewFile = null; + currentPreviewMode = null; + recoveryWaitingForAnchor = false; + } + + function rememberSessionFileMeta(meta = {}) { + const file = normalizeSessionPath(meta.file); + const sourceFile = normalizeSessionPath(meta.sourceFile); + const previewFile = normalizeSessionPath(meta.previewFile); + const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null); + + if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) { + currentPreviewMode = 'svelte-component'; + currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile); + currentSourceFile = sourceFile || currentSourceFile; + return; + } + + if (sourceFile || file) currentSourceFile = sourceFile || file; + if (previewFile) currentPreviewFile = previewFile; + if (previewMode) currentPreviewMode = previewMode; + } + + function applySavedSessionMeta(saved) { + if (!saved) return; + rememberSessionFileMeta(saved); + if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.action) selectedAction = saved.action; + if (saved.count) selectedCount = saved.count; + if (saved.previewMode) currentPreviewMode = saved.previewMode; + if (saved.paramValues && typeof saved.paramValues === 'object') { + paramsCurrentValues = { ...saved.paramValues }; + } + } + + function normalizePagePath(value) { + if (!value || typeof value !== 'string') return null; + try { + return new URL(value, location.origin).pathname; + } catch { + return value.split(/[?#]/)[0] || null; + } + } + + function pageMatchesCurrent(value) { + const path = normalizePagePath(value); + return !path || path === location.pathname; + } + + function isTerminalSessionSummary(session) { + return /^(completed|discarded|discard_requested|accept_requested)$/.test(String(session?.phase || '')); + } + + function findActiveSessionSummary(saved, activeSessions) { + if (!saved?.id || !Array.isArray(activeSessions)) return null; + return activeSessions.find((session) => + session?.id === saved.id + && pageMatchesCurrent(session.pageUrl || saved.pageUrl) + && !isTerminalSessionSummary(session) + ) || null; + } + + function clampVariantIndex(value, count) { + const num = Number(value); + const max = Number(count); + if (!Number.isFinite(num) || num < 1) return 0; + if (Number.isFinite(max) && max > 0 && num > max) return 0; + return Math.floor(num); + } + + function restoreSessionWithoutWrapper(reason, activeSessions) { + const saved = loadSession(); + if (!saved?.id || isSessionHandled(saved.id)) return false; + const savedState = String(saved.state || '').toUpperCase(); + if (savedState !== 'GENERATING' && savedState !== 'CYCLING') return false; + + const serverSession = findActiveSessionSummary(saved, activeSessions); + if (Array.isArray(activeSessions) && activeSessions.length > 0 && !serverSession) { + return false; + } + + currentSessionId = saved.id; + applySavedSessionMeta(serverSession); + applySavedSessionMeta(saved); + + expectedVariants = Number(saved.expected || serverSession?.expectedVariants || selectedCount || 0); + arrivedVariants = Number(saved.arrived || serverSession?.arrivedVariants || 0); + if (arrivedVariants <= 0 && currentPreviewFile) arrivedVariants = Number(serverSession?.expectedVariants || saved.expected || selectedCount || 0); + if (expectedVariants <= 0) expectedVariants = Number(serverSession?.expectedVariants || arrivedVariants || selectedCount || 0); + visibleVariant = clampVariantIndex(saved.visible, arrivedVariants || expectedVariants) + || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) + || (arrivedVariants > 0 ? 1 : 0); + + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + if (variantObserver) variantObserver.disconnect(); + variantObserver = startVariantObserver(currentSessionId); + saveSession(); + queueCheckpoint(reason || 'browser_restore_without_wrapper'); + + const restoreFile = currentPreviewMode === 'svelte-component' + ? currentPreviewFile + : (currentSourceFile || currentPreviewFile); + if (restoreFile) { + injectVariantsFromSource(restoreFile, currentSessionId); + return true; + } + + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return true; + } + + function restoreFromActiveSessions(activeSessions, reason) { + const wrapper = document.querySelector('[data-impeccable-variants]'); + if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false; + if (svelteComponentSession?.sessionId === currentSessionId) return false; + return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); + } + function saveSession() { if (!currentSessionId) return; // NOTE: scrollY is stored under a separate key (writeScrollY). Storing @@ -5869,6 +7108,11 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, + pageUrl: location.pathname, + paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, }); } @@ -5898,31 +7142,33 @@ void main() { } function cleanup() { - // Hide the wrapper immediately so variants disappear. DON'T structurally - // mutate the DOM yet - HMR from the agent's source rewrite is on its way, - // and a manual replaceChild under React causes NotFoundError when the - // reconciler later tries to remove a wrapper we already removed. - // Schedule a 2s fallback that does the manual swap only if HMR hasn't - // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const cleanupSessionId = currentSessionId; - if (cleanupSessionId) { + if (svelteComponentSession?.sessionId === cleanupSessionId) { + teardownSvelteComponentSession(true); + } else if (cleanupSessionId) { + // Hide the wrapper immediately so variants disappear. DON'T structurally + // mutate the DOM yet - HMR from the agent's source rewrite is on its way, + // and a manual replaceChild under React causes NotFoundError when the + // reconciler later tries to remove a wrapper we already removed. + // Schedule a 2s fallback that does the manual swap only if HMR hasn't + // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); if (wrapper) wrapper.style.display = 'none'; - } - setTimeout(function() { - if (!cleanupSessionId) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!wrapper) return; - const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - wrapper.parentElement.replaceChild(content, wrapper); - return; + setTimeout(function() { + if (!cleanupSessionId) return; + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) return; + const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); + if (orig) { + const content = orig.firstElementChild; + if (content) { + lateWrapper.parentElement.replaceChild(content, lateWrapper); + return; + } } - } - wrapper.remove(); - }, 2000); + lateWrapper.remove(); + }, 2000); + } hideBar(); hideHighlight(); stopScrollTracking(); @@ -5931,6 +7177,7 @@ void main() { clearScrollY(); finalizeInsertSession(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5938,9 +7185,9 @@ void main() { state = 'PICKING'; } - // --------------------------------------------------------------------------- + // // Toast - // --------------------------------------------------------------------------- + // function showToast(message, duration) { if (toastEl) toastEl.remove(); @@ -5964,7 +7211,7 @@ void main() { }); toastEl.id = PREFIX + '-toast'; toastEl.textContent = message; - document.body.appendChild(toastEl); + uiAppend(toastEl); requestAnimationFrame(() => { toastEl.style.opacity = '1'; toastEl.style.transform = 'translateX(-50%) translateY(0)'; @@ -5978,22 +7225,70 @@ void main() { }, duration); } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. function resumeSession() { const wrapper = document.querySelector('[data-impeccable-variants]'); - if (!wrapper) { clearSession(); clearHandled(); return false; } + if (!wrapper) { + if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } const sessionId = wrapper.dataset.impeccableVariants; // Don't resume if this session was already accepted/discarded if (isSessionHandled(sessionId)) return false; + // Svelte component sessions can't be resumed by counting DOM children: the + // wrapper holds a single mount target, not [data-impeccable-variant] nodes, + // and a page reload unmounts every compiled variant. Counting children here + // would strand the bar in CYCLING at 0/0. If there's no live in-memory mount + // for this wrapper, it's an orphan (reload / failed mount): drop it and let + // the live-server's SSE re-inject the manifest if the session is still live. + if (wrapper.dataset.impeccablePreview === 'svelte-component' + && svelteComponentSession?.sessionId !== sessionId) { + wrapper.remove(); + if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } + + if (wrapper.dataset.impeccablePreview === 'svelte-component') { + if (!svelteComponentSession?.mountedVariant) { + return true; + } + currentSessionId = sessionId; + expectedVariants = Number(wrapper.dataset.impeccableVariantCount) + || Number(svelteComponentSession.manifest?.count) + || expectedVariants + || 1; + arrivedVariants = expectedVariants; + const saved = loadSession(); + applySavedSessionMeta(saved); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = svelteComponentSession.mountedVariant > 0 && svelteComponentSession.mountedVariant <= arrivedVariants + ? svelteComponentSession.mountedVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = resolveSvelteComponentAnchor() + || wrapper.parentElement; + state = 'CYCLING'; + hideShaderOverlay(); + showBar('cycling'); + startScrollTracking(); + refreshParamsPanel(); + saveSession(); + queueCheckpoint('browser_resumed_svelte_component'); + return true; + } + currentSessionId = sessionId; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -6002,6 +7297,7 @@ void main() { // Restore state from localStorage if available const saved = loadSession(); if (saved && saved.id === sessionId) { + applySavedSessionMeta(saved); visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0); if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; @@ -6072,9 +7368,9 @@ void main() { return true; } - // --------------------------------------------------------------------------- + // // Global bar (always visible at bottom) - // --------------------------------------------------------------------------- + // let globalBarEl = null; let globalBarBrandEl = null; @@ -6166,6 +7462,8 @@ void main() { let pageChatExpanded = false; let steerLocked = false; let steerRequestId = null; + let steerPendingMessage = ''; + let steerInputWasFocused = false; let pageChatDotsEl = null; let steerAwaitTimer = null; let voiceRecognition = null; @@ -6179,7 +7477,7 @@ void main() { const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; - const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect'; + const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect'; const GLOBAL_BAR_SECTION_GAP = 8; const GLOBAL_BAR_INNER_GAP = 2; const GLOBAL_BAR_INNER_PAD_LEFT = 2; @@ -6323,7 +7621,7 @@ void main() { const attempt = () => { steerFocusRecoverTimer = null; if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (pageHasHostTextSelection()) { steerFocusRecoverTimer = setTimeout(attempt, 120); return; @@ -6344,7 +7642,7 @@ void main() { steerFocusSuspended = true; steerFocusPauseUntil = performance.now() + STEER_PAGE_FOCUS_PAUSE_MS; pagePointerGesture = { x: e.clientX, y: e.clientY, dragged: false }; - if (pageChatInput && document.activeElement === pageChatInput) { + if (pageChatInput && activeElementDeep() === pageChatInput) { pageChatInput.blur(); } } @@ -6404,7 +7702,7 @@ void main() { pickActive, pageChatReady: !!pageChatInput, pageChatExpanded, - active: steerFocusTargetLabel(document.activeElement), + active: steerFocusTargetLabel(activeElementDeep()), shouldSteer: shouldFocusSteerChat(), ...(extra || {}), }); @@ -6423,26 +7721,26 @@ void main() { function focusConfigureInput(reason) { steerFocusLog('focusConfigureInput', { reason }); const inputId = configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input'; - const input = document.getElementById(inputId); + const input = uiGetById(inputId); if (!input) { steerFocusLog('focusConfigureInput missing', { reason }); return; } setTimeout(() => { - const before = document.activeElement; + const before = activeElementDeep(); input.focus(); steerFocusLog('focusConfigureInput result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== input, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== input, }); }, 60); } function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; - const focused = document.activeElement === pageChatInput; + const focused = activeElementDeep() === pageChatInput; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; const P = pageChatPalette(); pageChatEl.style.borderColor = steerLocked @@ -6476,15 +7774,15 @@ void main() { } syncPageChatVisual(); pageChatInput.style.pointerEvents = 'auto'; - const before = document.activeElement; + const before = activeElementDeep(); try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== pageChatInput, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== pageChatInput, }); } @@ -6515,6 +7813,37 @@ void main() { return wrap; } + function keepSteerPointerInside(e, opts = {}) { + e.stopPropagation(); + if (opts.preventDefault !== false) e.preventDefault(); + } + + function preparePageChatInputForTyping() { + if (!pageChatEl || !pageChatInput) return false; + pageChatExpanded = true; + pageChatEl.dataset.expanded = 'true'; + pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; + pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return true; + } + + function focusPageChatInput(reason) { + if (!preparePageChatInputForTyping() || steerLocked) return false; + try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } + const focused = activeElementDeep() === pageChatInput; + if (focused) steerInputWasFocused = true; + syncPageChatFocusRing(); + return focused; + } + function clearSteerAwaitTimer() { if (steerAwaitTimer) { clearTimeout(steerAwaitTimer); @@ -6528,6 +7857,7 @@ void main() { if (!steerLocked || steerRequestId !== id) return; unlockSteerChat({ error: 'Steer timed out waiting for the agent. Check that live-poll is running and replies with steer_done.', + restoreMessage: steerPendingMessage, }); }, STEER_AWAIT_TIMEOUT_MS); } @@ -6538,19 +7868,12 @@ void main() { steerLocked = true; pageChatEl.dataset.processing = 'true'; pageChatInput.disabled = true; - pageChatInput.value = ''; - pageChatInput.blur(); + preparePageChatInputForTyping(); if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = true; pageChatVoiceBtn.style.display = 'none'; } - pageChatExpanded = false; - pageChatEl.dataset.expanded = 'false'; - pageChatEl.style.width = PAGE_CHAT_PROCESSING_W; pageChatEl.style.cursor = 'default'; - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; pageChatInput.style.pointerEvents = 'none'; if (pageChatHint) { pageChatHint.style.display = 'none'; @@ -6568,17 +7891,26 @@ void main() { function unlockSteerChat(opts) { clearSteerAwaitTimer(); + const restoreMessage = typeof opts?.restoreMessage === 'string' ? opts.restoreMessage : ''; + const keepExpanded = Boolean(opts?.error && restoreMessage); steerLocked = false; + const completedId = steerRequestId; steerRequestId = null; if (!pageChatEl) return; pageChatEl.dataset.processing = 'false'; pageChatEl.removeAttribute('aria-busy'); pageChatEl.setAttribute('aria-label', 'Steer the page'); - pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W; + pageChatExpanded = keepExpanded; + pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false'; + pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W; pageChatEl.style.cursor = 'pointer'; if (pageChatInput) { pageChatInput.disabled = false; - pageChatInput.value = ''; + pageChatInput.value = keepExpanded ? restoreMessage : ''; + pageChatInput.style.width = keepExpanded ? '' : '0'; + pageChatInput.style.padding = keepExpanded ? '0 6px' : '0'; + pageChatInput.style.opacity = keepExpanded ? '1' : '0'; + pageChatInput.style.pointerEvents = 'auto'; } if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = false; @@ -6586,18 +7918,28 @@ void main() { } if (pageChatHint) { pageChatHint.textContent = 'Steer'; - pageChatHint.style.display = ''; - pageChatHint.style.visibility = ''; + pageChatHint.style.display = keepExpanded ? 'none' : ''; + pageChatHint.style.visibility = keepExpanded ? 'hidden' : ''; + pageChatHint.style.opacity = keepExpanded ? '0' : '1'; } if (pageChatDotsEl?.parentNode) { pageChatDotsEl.remove(); pageChatDotsEl = null; } + steerPendingMessage = keepExpanded ? restoreMessage : ''; + steerInputWasFocused = false; syncPageChatChrome(); syncPageChatFocusRing(); if (opts?.error) showToast(String(opts.error), 5000); else if (opts?.message) showToast(String(opts.message), 4000); - syncPageChatFocus('steer-unlock'); + if (completedId) { + sendSteerCheckpoint(completedId, opts?.error ? 'steer_error' : 'steer_done', { + message: opts?.message || opts?.error || '', + file: opts?.file || '', + }); + } + if (keepExpanded) focusPageChatInput('steer-error-restore'); + else syncPageChatFocus('steer-unlock'); } function steerSpeechRecognitionCtor() { @@ -6651,7 +7993,7 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = document.getElementById(PREFIX + '-configure-voice'); + const voiceBtn = uiGetById(PREFIX + '-configure-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -6784,7 +8126,7 @@ void main() { } function configureVoiceContext() { - const input = document.getElementById( + const input = uiGetById( configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input', ); return { @@ -6819,26 +8161,37 @@ void main() { if (!text || steerLocked) return; const id = id8(); steerRequestId = id; + steerPendingMessage = text; + if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); lockSteerChat(); scheduleSteerAwaitTimeout(id); + sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); sendEvent({ type: 'steer', id, message: text, pageUrl: location.href, }).then((res) => { - if (!res) unlockSteerChat({ error: 'Could not reach live server' }); + if (!res) { + sendSteerCheckpoint(id, 'steer_send_failed', { message: text }); + unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text }); + } }); } function maybeCompleteSteer(msg) { if (!steerRequestId || msg.id !== steerRequestId) return false; if (msg.type === 'steer_done') { - unlockSteerChat({ message: msg.message }); + unlockSteerChat({ message: msg.message, file: msg.file }); + if (msg.file && /\.svelte(?:$|\?)/.test(String(msg.file))) { + setTimeout(() => { + if (!steerLocked) showToast('Steer applied. Reload if the page has not refreshed yet.', 5000); + }, 4500); + } return true; } if (msg.type === 'error') { - unlockSteerChat({ error: msg.message || 'Steer failed' }); + unlockSteerChat({ error: msg.message || 'Steer failed', restoreMessage: steerPendingMessage }); return true; } return false; @@ -6847,21 +8200,10 @@ void main() { function expandPageChat(opts) { const focus = !opts || opts.focus !== false; if (!pageChatEl || !pageChatInput || steerLocked) return; - pageChatExpanded = true; - pageChatEl.dataset.expanded = 'true'; - pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; - pageChatEl.style.cursor = 'text'; - if (pageChatHint) { - pageChatHint.style.display = 'none'; - pageChatHint.style.opacity = '0'; - } - pageChatInput.style.width = ''; - pageChatInput.style.padding = '0 6px'; - pageChatInput.style.opacity = '1'; - pageChatInput.style.pointerEvents = 'auto'; + preparePageChatInputForTyping(); syncPageChatChrome(); syncPageChatFocusRing(); - if (focus) pageChatInput.focus(); + if (focus) focusPageChatInput('expand-page-chat'); } function collapsePageChat(opts) { @@ -6878,7 +8220,7 @@ void main() { } else { pageChatInput.style.pointerEvents = 'auto'; } - if (pageChatHint && document.activeElement !== pageChatInput) { + if (pageChatHint && activeElementDeep() !== pageChatInput) { pageChatHint.style.display = ''; pageChatHint.style.opacity = '1'; } @@ -6952,7 +8294,7 @@ void main() { pageChatEl.appendChild(pageChatInput); pageChatEl.appendChild(pageChatVoiceBtn); - if (!document.getElementById(PREFIX + '-page-chat-style')) { + if (!uiGetById(PREFIX + '-page-chat-style')) { const s = document.createElement('style'); s.id = PREFIX + '-page-chat-style'; s.textContent = @@ -6966,23 +8308,34 @@ void main() { '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } - pageChatEl.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { + keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat(); + expandPageChat({ focus: false }); + focusPageChatInput('page-chat-click'); }); - pageChatVoiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); + pageChatVoiceBtn.addEventListener('mousedown', keepSteerPointerInside); pageChatVoiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); + keepSteerPointerInside(e); if (steerLocked) return; toggleSteerVoice(); }); + pageChatInput.addEventListener('pointerdown', keepSteerPointerInside); + pageChatInput.addEventListener('mousedown', keepSteerPointerInside); + pageChatInput.addEventListener('click', (e) => { + keepSteerPointerInside(e); + if (!steerLocked) focusPageChatInput('page-chat-input-click'); + }); + pageChatInput.addEventListener('input', () => { syncPageChatVisual(); }); @@ -6995,7 +8348,7 @@ void main() { syncPageChatFocusRing(); setTimeout(() => { if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (!pageChatInput.value.trim()) collapsePageChat(); scheduleSteerFocusRecover('steer-blur-recover'); }, 120); @@ -7039,7 +8392,7 @@ void main() { globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false'; globalBarBrandEl.setAttribute('aria-label', connected ? 'Impeccable live mode' - : 'Impeccable live mode: agent not polling'); + : 'Impeccable live mode - agent not polling'); globalBarBrandEl.removeAttribute('title'); globalBarBrandEl.style.cursor = connected ? 'default' : 'help'; const mark = globalBarBrandEl.querySelector('[data-brand-mark]'); @@ -7077,7 +8430,7 @@ void main() { }); agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip'; agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP; - document.body.appendChild(agentPollTooltipEl); + uiAppend(agentPollTooltipEl); return agentPollTooltipEl; } @@ -7131,7 +8484,7 @@ void main() { // Custom focus-visible for bar buttons. Browser default is a heavy // blue ring that looks jarring on the dark capsule. Replace with a // soft accent-tinted inner ring that respects the bar's palette. - if (!document.getElementById(PREFIX + '-bar-focus-style')) { + if (!uiGetById(PREFIX + '-bar-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-bar-focus-style'; s.textContent = @@ -7143,7 +8496,7 @@ void main() { '@keyframes impeccable-agent-dot { 0%, 100% { opacity: 0.45; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1); } }' + '#' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: impeccable-agent-dot 1.4s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: none; opacity: 0.9; } }'; - document.head.appendChild(s); + uiAppendStyle(s); } globalBarEl = el('div', { @@ -7176,7 +8529,7 @@ void main() { brand.id = PREFIX + '-global-bar-brand'; brand.dataset.agentConnected = 'false'; brand.setAttribute('role', 'img'); - brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling'); + brand.setAttribute('aria-label', 'Impeccable live mode - agent not polling'); const brandMark = el('span', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -7211,7 +8564,7 @@ void main() { inner.id = PREFIX + '-global-bar-inner'; globalBarEl.appendChild(inner); - // --- button factory: icon-only at rest, label slides in on hover/active --- + // Button factory: icon-only at rest, label slides in on hover/active. function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { const b = el('button', { position: 'relative', @@ -7506,6 +8859,7 @@ void main() { color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', }); + exitBtn.id = PREFIX + '-exit'; exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = 'oklch(58% 0.15 35)'; exitBtn.style.background = P.exitHover; }); @@ -7530,8 +8884,8 @@ void main() { try { window.focus(); } catch { /* in-app preview may block */ } }, true); - document.body.appendChild(pendingDockEl); - document.body.appendChild(globalBarEl); + uiAppend(pendingDockEl); + uiAppend(globalBarEl); defangOutsideHandlers(pendingDockEl); defangOutsideHandlers(globalBarEl); @@ -7553,11 +8907,11 @@ void main() { } function updateGlobalBarState() { - const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); - const detectBadge = document.getElementById(PREFIX + '-detect-badge'); - const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); - const insertToggle = document.getElementById(PREFIX + '-insert-toggle'); - const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const detectToggle = uiGetById(PREFIX + '-detect-toggle'); + const detectBadge = uiGetById(PREFIX + '-detect-badge'); + const pickToggle = uiGetById(PREFIX + '-pick-toggle'); + const insertToggle = uiGetById(PREFIX + '-insert-toggle'); + const designToggle = uiGetById(PREFIX + '-design-toggle'); const theme = globalBarEl?.dataset.theme || 'light'; const P = barPaletteForTheme(theme); @@ -7751,8 +9105,9 @@ void main() { pendingApplyInFlight = false; } if (globalBarEl) { - globalBarEl.style.transform = 'translateY(100%)'; - setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300); + globalBarEl.style.transition = 'none'; + globalBarEl.remove(); + globalBarEl = null; } pageChatEl = null; pageChatInput = null; @@ -7765,6 +9120,7 @@ void main() { if (barEl) { barEl.remove(); barEl = null; } if (pickerEl) { pickerEl.remove(); pickerEl = null; } if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; } + if (editBadgeProxyRoot) { editBadgeProxyRoot.remove(); editBadgeProxyRoot = null; editBadgeProxyByTarget = new Map(); } if (evtSource) { evtSource.close(); evtSource = null; } document.removeEventListener('mousemove', handleMouseMove, true); document.removeEventListener('click', handleClick, true); @@ -7777,9 +9133,9 @@ void main() { console.log('[impeccable] Live mode exited.'); } - // --------------------------------------------------------------------------- + // // Design System Panel - visualizes the project's .impeccable/design.json sidecar - // --------------------------------------------------------------------------- + // const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; const DESIGN_PANEL_WIDTH = 440; @@ -7847,7 +9203,7 @@ void main() { root.className = 'root'; designShadow.appendChild(root); - document.body.appendChild(designHost); + uiAppend(designHost); // The host is pointer-events: none; the panel inside the shadow DOM // manages its own auto/none. Events bubble through the shadow boundary, // so attaching here silences host-page outside-interaction handlers @@ -7889,7 +9245,7 @@ void main() { .root * { box-sizing: border-box; } button { font: inherit; color: inherit; } - /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + /* Panel shell: chrome matches the bar; body canvas stays neutral */ .panel { position: fixed; top: 12px; bottom: 72px; right: 12px; width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); @@ -7955,7 +9311,7 @@ void main() { .panel-body::-webkit-scrollbar { width: 8px; } .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } - /* --- States --- */ + /* States */ .empty, .loading, .error { margin: 16px 4px; padding: 28px 20px; text-align: center; @@ -7966,7 +9322,7 @@ void main() { .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; } .error { color: oklch(45% 0.15 25); } - /* --- Stale hint --- */ + /* Stale hint */ .stale { display: flex; align-items: center; gap: 8px; margin: 8px 4px 12px; @@ -7979,7 +9335,7 @@ void main() { .stale-text { flex: 1; min-width: 0; } .stale-text strong { color: ${DP.ink}; font-weight: 600; } - /* --- Parsed-md fallback banner --- */ + /* Parsed-md fallback banner */ .parsed-md-cta { margin: 8px 4px 14px; padding: 14px 16px; @@ -7991,7 +9347,7 @@ void main() { .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; } .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; } - /* --- Tile primitives --- */ + /* Tile primitives */ .tile { position: relative; background: ${DP.tile}; @@ -8010,7 +9366,7 @@ void main() { } .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; } - /* --- Color tile --- */ + /* Color tile */ .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; } .c-tile:hover { transform: translateY(-1px); } .c-hero { @@ -8025,7 +9381,7 @@ void main() { .c-ramp > span { flex: 1; } .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Type tile --- */ + /* Type tile */ .t-tile { } .t-specimen { margin: 4px 0 6px; @@ -8035,7 +9391,7 @@ void main() { .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; } .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Shadow tile --- */ + /* Shadow tile */ .s-tile { } .s-surface { height: 60px; margin: 8px 2px 10px; @@ -8045,14 +9401,14 @@ void main() { .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; } .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; } - /* --- Radii strip --- */ + /* Radii strip */ .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; } .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; } .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); } .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; } .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; } - /* --- Component tile (hosts live primitives) --- */ + /* Component tile (hosts live primitives) */ .cmp-tile { } .cmp-stage { margin: 12px -4px 0; @@ -8066,7 +9422,7 @@ void main() { .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; } .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; } - /* --- Collapsible --- */ + /* Collapsible */ .coll { margin: 0 4px 8px; background: ${DP.tile}; @@ -8131,7 +9487,7 @@ void main() { .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; } .coll .overview-body li { margin-bottom: 3px; } - /* --- raw tab markdown (unchanged layout, neutralized palette) --- */ + /* raw tab markdown (unchanged layout, neutralized palette) */ .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; } .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; } .md h1 { font-size: 18px; } @@ -8303,7 +9659,7 @@ void main() { return box; } - // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 --- + // Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 function renderDesignVisual(body, parsed, sidecar) { const frontmatter = parsed?.frontmatter || {}; @@ -8667,7 +10023,7 @@ void main() { return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components'); } - // --- Collapsibles --------------------------------------------------------- + // Collapsibles. function buildCollapsible(key, label, count) { const wrap = document.createElement('div'); @@ -8775,7 +10131,7 @@ void main() { return s.replace(/\s+#.*$/, '').trim(); } - // --- Raw tab: minimal markdown renderer (subset) -------------------------- + // Raw tab: minimal markdown renderer (subset) function renderRawTab(body, md) { const wrap = document.createElement('div'); @@ -8908,9 +10264,9 @@ void main() { } catch { /* ignore */ } } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // function init() { try { history.scrollRestoration = 'manual'; } catch {} diff --git a/.trae/skills/impeccable/scripts/live-completion.mjs b/.trae/skills/impeccable/scripts/live-completion.mjs index 86b637fff..986773066 100644 --- a/.trae/skills/impeccable/scripts/live-completion.mjs +++ b/.trae/skills/impeccable/scripts/live-completion.mjs @@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.mode === 'error') return 'error'; + if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error'; return 'agent_done'; } diff --git a/.trae/skills/impeccable/scripts/live-inject.mjs b/.trae/skills/impeccable/scripts/live-inject.mjs index b9d3df41d..3a1f36e46 100644 --- a/.trae/skills/impeccable/scripts/live-inject.mjs +++ b/.trae/skills/impeccable/scripts/live-inject.mjs @@ -17,11 +17,38 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './impeccable-paths.mjs'; +import { + applySvelteKitLiveAdapter, + detectSvelteKitProject, + removeSvelteKitLiveAdapter, +} from './live-sveltekit-adapter.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start'; +const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end'; + +export const LIVE_IGNORE_PATTERNS = Object.freeze([ + '.impeccable/hook.cache.json', + '.impeccable/live/server.json', + '.impeccable/live/sessions/', + '.impeccable/live/previews/', + '.impeccable/live/annotations/', + '.impeccable/live/cache/', + '.impeccable/live/manual-edit-apply-transaction.json', + '.impeccable/live/manual-edit-events.jsonl', + '.impeccable/live/manual-edit-evidence/', + '.impeccable/live/pending-manual-edits.json', + '.impeccable/live/deferred-svelte-component-accepts.json', + '.impeccable-live.json', + '.impeccable-live/', + 'node_modules/.impeccable-live/', + 'src/lib/impeccable/ImpeccableLiveRoot.svelte', + 'src/lib/impeccable/__runtime.js', + 'src/lib/impeccable/[0-9a-f]*/', +]); /** * Hard-excluded directory patterns. These are NEVER user-facing pages and @@ -83,8 +110,14 @@ Output (JSON): validateConfig(config); const resolvedFiles = resolveFiles(process.cwd(), config); + const svelteKit = detectSvelteKitProject(process.cwd(), config); if (args.includes('--remove')) { + if (svelteKit) { + const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config }); + console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; @@ -110,6 +143,13 @@ Output (JSON): console.error(JSON.stringify({ ok: false, error: 'missing_port' })); process.exit(1); } + const gitIgnore = ensureLiveGitIgnores(process.cwd()); + + if (svelteKit) { + const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config }); + console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); @@ -129,10 +169,68 @@ Output (JSON): }; }); const anyInserted = results.some((r) => r.inserted); - console.log(JSON.stringify({ ok: anyInserted, port, results })); + console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results })); if (!anyInserted) process.exit(1); } +export function ensureLiveGitIgnores(cwd = process.cwd()) { + const target = resolveIgnoreTarget(cwd); + const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : ''; + const block = [ + IGNORE_MARKER_OPEN, + ...LIVE_IGNORE_PATTERNS, + IGNORE_MARKER_CLOSE, + ].join('\n'); + const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`); + + let updated; + if (markerRe.test(existing)) { + updated = existing.replace(markerRe, block); + } else { + const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n'; + updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`; + } + + if (updated !== existing) { + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + fs.writeFileSync(target.path, updated, 'utf-8'); + } + + return { + file: path.relative(cwd, target.path).split(path.sep).join('/'), + mode: target.mode, + changed: updated !== existing, + patterns: [...LIVE_IGNORE_PATTERNS], + }; +} + +function resolveIgnoreTarget(cwd) { + const gitExcludePath = resolveGitInfoExcludePath(cwd); + if (gitExcludePath) { + return { path: gitExcludePath, mode: 'git-info-exclude' }; + } + return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' }; +} + +function resolveGitInfoExcludePath(cwd) { + const dotGit = path.join(cwd, '.git'); + if (!fs.existsSync(dotGit)) return null; + + const stat = fs.statSync(dotGit); + if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude'); + if (!stat.isFile()) return null; + + const body = fs.readFileSync(dotGit, 'utf-8').trim(); + const match = body.match(/^gitdir:\s*(.+)$/i); + if (!match) return null; + const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]); + return path.join(gitDir, 'info', 'exclude'); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Expand config.files (which may contain glob patterns) into a literal list * of existing file paths relative to rootDir. Literal entries pass through; diff --git a/.trae/skills/impeccable/scripts/live-insert.mjs b/.trae/skills/impeccable/scripts/live-insert.mjs index 09d4d55be..0658e9914 100644 --- a/.trae/skills/impeccable/scripts/live-insert.mjs +++ b/.trae/skills/impeccable/scripts/live-insert.mjs @@ -21,6 +21,11 @@ import { buildCssAuthoring, buildCssSelectorPrefixExamples, } from './live-wrap.mjs'; +import { + buildSvelteComponentCssAuthoring, + scaffoldSvelteComponentInsertSession, + shouldUseSvelteComponentInjection, +} from './live-svelte-component.mjs'; const INSERT_POSITIONS = new Set(['before', 'after']); @@ -192,6 +197,41 @@ Output (JSON): const styleMode = detectStyleMode(targetFile); const isJsx = commentSyntax.open === '{/*'; const spliceIndex = computeInsertLine(startLine, endLine, position); + const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); + + if (shouldUseSvelteComponentInjection(targetFile)) { + const session = scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile: relTargetFile, + insertLine: spliceIndex + 1, + position, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + anchorLines: lines.slice(startLine, endLine + 1), + cwd: process.cwd(), + }); + console.log(JSON.stringify({ + mode: 'insert', + position, + file: session.manifestFile, + sourceFile: relTargetFile, + previewMode: 'svelte-component', + componentDir: session.componentDir, + propContract: session.propContract, + insertLine: 1, + sourceInsertLine: spliceIndex + 1, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + commentSyntax, + styleMode: 'svelte-component', + styleTag: null, + cssSelectorPrefixExamples: [], + cssAuthoring: buildSvelteComponentCssAuthoring(count), + })); + return; + } + const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? ''; @@ -216,7 +256,7 @@ Output (JSON): console.log(JSON.stringify({ mode: 'insert', position, - file: path.relative(process.cwd(), targetFile), + file: relTargetFile, insertLine: insertLine + 1, commentSyntax, styleMode: styleMode.mode, diff --git a/.trae/skills/impeccable/scripts/live-poll.mjs b/.trae/skills/impeccable/scripts/live-poll.mjs index fad836612..1e1259fbc 100644 --- a/.trae/skills/impeccable/scripts/live-poll.mjs +++ b/.trae/skills/impeccable/scripts/live-poll.mjs @@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs'; // that ceiling and loop in `pollOnce` to synthesize a long poll without // depending on the standalone undici package. export const PER_REQUEST_TIMEOUT_MS = 270_000; +export const DEFAULT_EVENT_LEASE_MS = 600_000; const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); @@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) { ? totalDeadline - Date.now() : PER_REQUEST_TIMEOUT_MS; const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); - const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); + const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`); if (res.status === 401) { const err = new Error('Authentication failed. The server token may have changed.'); @@ -317,7 +318,7 @@ Modes: Options: --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) - --file PATH Attach a source file path to the reply (generate flow) + --file PATH Attach a source file path to the reply (generate/steer flow) --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --help Show this help message diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs index 16c8285b9..cd1091b88 100644 --- a/.trae/skills/impeccable/scripts/live-server.mjs +++ b/.trae/skills/impeccable/scripts/live-server.mjs @@ -42,6 +42,10 @@ import { } from './live-manual-edits-buffer.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs'; +import { + applyDeferredSvelteComponentAccepts, + removeAllSvelteComponentSessions, +} from './live-svelte-component.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated @@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; +const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2; const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); function tombstoneTimedOutApplyId(eventId, details = {}) { @@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) { return entry.event; } entry.leaseUntil = Date.now() + leaseMs; + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return entry.event; } @@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) { const acknowledged = state.pendingEvents[idx].event; state.pendingEvents.splice(idx, 1); scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return acknowledged; } +function findPendingEventById(id) { + if (!id) return null; + const entry = state.pendingEvents.find((item) => item.event?.id === id); + return entry?.event || null; +} + function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; return `live-poll.mjs --reply ${id} done --data ''`; @@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) { return summary; } +function summarizeActiveSessionForClient(snapshot = {}) { + return { + id: snapshot.id, + phase: snapshot.phase, + pageUrl: snapshot.pageUrl ?? null, + sourceFile: snapshot.sourceFile ?? null, + previewFile: snapshot.previewFile ?? null, + previewMode: snapshot.previewMode ?? null, + expectedVariants: snapshot.expectedVariants ?? 0, + arrivedVariants: snapshot.arrivedVariants ?? 0, + visibleVariant: snapshot.visibleVariant ?? null, + checkpointRevision: snapshot.checkpointRevision ?? 0, + paramValues: snapshot.paramValues || {}, + }; +} + +function activeSessionSummaries() { + if (!state.sessionStore) return []; + return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot)); +} + +function cancelQueuedAnonymousExitEvents() { + let removed = 0; + for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) { + const event = state.pendingEvents[i]?.event; + if (event?.type !== 'exit' || event.id) continue; + state.pendingEvents.splice(i, 1); + removed += 1; + } + if (removed > 0) { + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); + } + return removed; +} + function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { const canceledById = new Map(); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); @@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() { clearTimeout(state.leaseTimer); state.leaseTimer = null; } - if (state.pendingPolls.length === 0) return; const now = Date.now(); const nextLeaseUntil = state.pendingEvents .map((entry) => entry.leaseUntil || 0) @@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() { state.leaseTimer = setTimeout(() => { state.leaseTimer = null; flushPendingPolls(); - }, Math.max(0, nextLeaseUntil - now)); + broadcastAgentPollingIfChanged(); + }, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS)); } function flushPendingPolls() { @@ -1032,7 +1082,9 @@ function flushPendingPolls() { } function agentPollingConnected() { - return state.pendingPolls.length > 0; + const now = Date.now(); + return state.pendingPolls.length > 0 + || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now); } function broadcastAgentPollingIfChanged() { @@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/status') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } - const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; + const sessions = activeSessionSummaries(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', @@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/events' && req.method === 'GET') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + clearTimeout(state.exitTimer); + state.exitTimer = null; + cancelQueuedAnonymousExitEvents(); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { type: 'connected', hasProjectContext: hasProjectContext(), agentPolling: agentPollingConnected(), + activeSessions: activeSessionSummaries(), }) + '\n\n'); state.sseClients.add(res); - clearTimeout(state.exitTimer); // Keepalive: SSE comment every 30s prevents silent connection drops. const heartbeat = setInterval(() => { @@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } } + if (msg.type === 'exit') { + cleanupSvelteComponentSessionsBeforeExit(); + } if (msg.type !== 'checkpoint') { enqueueEvent(msg); } @@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) { }); } +function sessionFileMetadataFromPollReply(file) { + if (!file || typeof file !== 'string') return { file }; + const normalized = file.split(path.sep).join('/'); + const base = { file: normalized }; + if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base; + if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base; + + let full; + try { + full = path.resolve(process.cwd(), normalized); + const rel = path.relative(process.cwd(), full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base; + } catch { + return base; + } + + try { + const manifest = JSON.parse(fs.readFileSync(full, 'utf-8')); + if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base; + return { + file: String(manifest.sourceFile).split(path.sep).join('/'), + sourceFile: String(manifest.sourceFile).split(path.sep).join('/'), + previewFile: normalized, + previewMode: 'svelte-component', + }; + } catch { + return base; + } +} + function handlePollPost(req, res) { let body = ''; req.on('data', (c) => { body += c; }); @@ -1965,6 +2053,16 @@ function handlePollPost(req, res) { res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); return; } + const pendingEventBeforeAck = findPendingEventById(msg.id); + if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done' + && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'steer_done_requires_file_or_message', + hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.', + })); + return; + } const acknowledgedEvent = acknowledgePendingEvent(msg.id); let skipJournalReply = false; let existingSession = null; @@ -1987,6 +2085,7 @@ function handlePollPost(req, res) { })); return; } + const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); if (state.sessionStore && msg.id && !skipJournalReply) { try { const eventType = msg.type === 'steer_done' @@ -2001,7 +2100,10 @@ function handlePollPost(req, res) { state.sessionStore.appendEvent({ type: eventType, id: msg.id, - file: msg.file, + file: replyFileMeta.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, message: msg.message, sourceEventType: acknowledgedEvent?.type, carbonize: msg.data?.carbonize === true, @@ -2010,7 +2112,16 @@ function handlePollPost(req, res) { } flushPendingPolls(); // Forward the reply to the browser via SSE - broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + broadcast({ + type: msg.type || 'done', + id: msg.id, + message: msg.message, + file: msg.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, + data: msg.data, + }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); @@ -2023,6 +2134,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { + cleanupSvelteComponentSessionsBeforeExit(); removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; @@ -2037,6 +2149,25 @@ function shutdown() { process.exit(0); } +function cleanupSvelteComponentSessionsBeforeExit() { + try { + removeAllSvelteComponentSessions(process.cwd()); + } catch (err) { + console.warn('[impeccable] Svelte component session cleanup failed:', err.message); + } +} + +function applyLegacyDeferredAcceptsOnStartup() { + try { + const result = applyDeferredSvelteComponentAccepts(process.cwd()); + if (result.applied > 0 || result.failed > 0) { + console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result)); + } + } catch (err) { + console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({ cwd: process.cwd(), reason: 'manual_edit_server_start_recovered_abandoned_transaction', }); +applyLegacyDeferredAcceptsOnStartup(); restorePendingEventsFromStore(); pruneStaleManualApplyEvidence(process.cwd()); const portArg = args.find(a => a.startsWith('--port=')); diff --git a/.trae/skills/impeccable/scripts/live-session-store.mjs b/.trae/skills/impeccable/scripts/live-session-store.mjs index 7562e3d5b..5ec4d34d4 100644 --- a/.trae/skills/impeccable/scripts/live-session-store.mjs +++ b/.trae/skills/impeccable/scripts/live-session-store.mjs @@ -106,6 +106,8 @@ function baseSnapshot(id) { phase: 'new', pageUrl: null, sourceFile: null, + previewFile: null, + previewMode: null, expectedVariants: 0, arrivedVariants: 0, visibleVariant: null, @@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; - next.sourceFile = event.file ?? next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0); next.pendingEventSeq = null; next.pendingEvent = null; if (event.carbonize === true) { @@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { } break; case 'checkpoint': + if (COMPLETED_PHASES.has(next.phase)) { + next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null }); + break; + } if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { next.phase = event.phase ?? next.phase; next.checkpointRevision = event.revision ?? next.checkpointRevision; next.activeOwner = event.owner ?? next.activeOwner; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.visibleVariant = event.visibleVariant ?? next.visibleVariant; + next.sourceFile = event.sourceFile ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; if (event.paramValues) next.paramValues = { ...event.paramValues }; } else { next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); @@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'steer_done': next.phase = 'steer_done'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.message = event.message ?? next.message; next.pendingEventSeq = null; next.pendingEvent = null; break; @@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'complete': next.phase = 'completed'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; next.pendingEventSeq = null; next.pendingEvent = null; break; diff --git a/.trae/skills/impeccable/scripts/live-svelte-component.mjs b/.trae/skills/impeccable/scripts/live-svelte-component.mjs new file mode 100644 index 000000000..dc35dc0ce --- /dev/null +++ b/.trae/skills/impeccable/scripts/live-svelte-component.mjs @@ -0,0 +1,826 @@ +/** + * Svelte live-mode component injection helpers. + * + * Variants are real .svelte components under node_modules/.impeccable-live//. + * The browser mounts them via Svelte 5 mount(); accept inlines the chosen + * variant back into the route source with props mapped to original bindings. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; + +export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live'; +export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`; +export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json'; + +const MUSTACHE_RE = /\{([^{}]+)\}/g; + +export function shouldUseSvelteComponentInjection(filePath) { + if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false; + return path.extname(filePath).toLowerCase() === '.svelte'; +} + +export function componentSessionDir(id, cwd = process.cwd()) { + return path.join(cwd, SVELTE_COMPONENT_ROOT, id); +} + +export function manifestPathForSession(id, cwd = process.cwd()) { + return path.join(componentSessionDir(id, cwd), 'manifest.json'); +} + +export function ensureRuntimeHelper(cwd = process.cwd()) { + const file = path.join(cwd, SVELTE_RUNTIME_FILE); + if (fs.existsSync(file)) return file; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8'); + return file; +} + +/** + * Extract ordered unique mustache expressions from markup (not inside ). + */ +export function extractMustacheExpressions(text) { + const expressions = []; + const seen = new Set(); + const lines = String(text || '').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('\n` + : ''; + return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n\n`; +} + +function buildInsertVariantStub(variantNum) { + return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; +} + +export function scaffoldSvelteComponentSession({ + id, + count, + sourceFile, + sourceStartLine, + sourceEndLine, + originalLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const originalMarkup = originalLines.join('\n'); + const contract = buildPropContract(extractMustacheExpressions(originalMarkup)); + const originalWithProps = substituteExprsWithProps(originalMarkup, contract); + + const manifest = { + id, + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + sourceStartLine, + sourceEndLine, + count, + propContract: contract, + originalMarkup, + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: contract, + }; +} + +export function scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile, + insertLine, + position, + anchorStartLine, + anchorEndLine, + anchorLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const anchorMarkup = (anchorLines || []).join('\n'); + const manifest = { + id, + mode: 'insert', + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + insertLine, + position, + anchorStartLine, + anchorEndLine, + originalMarkup: anchorMarkup, + anchorMarkup, + count, + propContract: [], + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: [], + }; +} + +export function findSvelteComponentManifest(id, cwd = process.cwd()) { + const direct = manifestPathForSession(id, cwd); + if (fs.existsSync(direct)) { + return readManifest(direct); + } + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return null; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(root, entry.name, 'manifest.json'); + if (!fs.existsSync(candidate)) continue; + try { + const manifest = readManifest(candidate); + if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; + } catch { /* skip */ } + } + return null; +} + +export function readManifest(manifestPath) { + const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + return { + ...data, + manifestPath, + }; +} + +export function resolveSourceFile(sourceFile, cwd = process.cwd()) { + if (!sourceFile || path.isAbsolute(sourceFile)) { + throw new Error('Invalid svelte-component source file'); + } + const full = path.resolve(cwd, sourceFile); + const rel = path.relative(cwd, full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('Svelte-component source file escapes project root'); + } + if (!fs.existsSync(full)) { + throw new Error('Svelte-component source file not found: ' + sourceFile); + } + return full; +} + +function appendCssToSvelteStyle(lines, cssLines) { + const closeIdx = findLastStyleCloseLine(lines); + const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; + if (closeIdx === -1) { + return [...lines, '', '']; + } + return [ + ...lines.slice(0, closeIdx), + ...prepared, + ...lines.slice(closeIdx), + ]; +} + +function findLastStyleCloseLine(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + if (/<\/style\s*>/.test(lines[i])) return i; + } + return -1; +} + +function bakeParamValuesInCss(cssLines, paramValues) { + if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; + return cssLines.map((line) => { + let out = line; + for (const [key, value] of Object.entries(paramValues)) { + const varName = `--p-${key}`; + out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); + } + return out; + }); +} + +function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { + const css = String((cssLines || []).join('\n')); + if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; + + const rules = parseCssRules(css); + const output = []; + for (const rule of rules) { + appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); + } + return output.join('\n') + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ''); +} + +function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { + const prelude = rule.prelude.trim(); + const body = rule.body.trim(); + if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; + + if (/^@scope\b/i.test(prelude)) { + if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; + const inner = parseCssRules(body); + for (const innerRule of inner) { + const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); + if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; + output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); + } + return; + } + + const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); + if (!rewrittenPrelude) return; + output.push(formatCssRule(rewrittenPrelude, body)); +} + +function parseCssRules(css) { + const rules = []; + const text = String(css || ''); + let i = 0; + while (i < text.length) { + while (i < text.length && /\s/.test(text[i])) i++; + const preludeStart = i; + while (i < text.length && text[i] !== '{') i++; + if (i >= text.length) break; + const prelude = text.slice(preludeStart, i).trim(); + i++; + const bodyStart = i; + let depth = 1; + let quote = null; + let comment = false; + while (i < text.length && depth > 0) { + const ch = text[i]; + const next = text[i + 1]; + if (comment) { + if (ch === '*' && next === '/') { + comment = false; + i += 2; + continue; + } + i++; + continue; + } + if (quote) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === quote) quote = null; + i++; + continue; + } + if (ch === '/' && next === '*') { + comment = true; + i += 2; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); + if (prelude) rules.push({ prelude, body }); + } + return rules; +} + +function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { + const selectors = splitSelectorList(prelude); + const rewritten = []; + for (const selector of selectors) { + const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); + if (next) rewritten.push(next); + } + return rewritten.join(', '); +} + +function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { + let out = selector.trim(); + const hasVariant = /data-impeccable-variant/.test(out); + if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; + if (hasVariant) { + out = out.replace(variantSelectorRegex(variantNum), ''); + out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); + } + + const paramResult = rewriteParamSelectors(out, paramValues); + if (!paramResult.keep) return ''; + out = paramResult.selector; + + out = out + .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') + .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') + .replace(/\s+/g, ' ') + .trim(); + + out = out.replace(/^[>+~]\s*/, '').trim(); + if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; + return out; +} + +function rewriteParamSelectors(selector, paramValues) { + let keep = true; + const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { + if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; + const actual = paramValues[key]; + if (expected != null && String(actual) !== String(expected)) { + keep = false; + return ''; + } + if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { + keep = false; + return ''; + } + return ''; + }); + return { keep, selector: next }; +} + +function splitSelectorList(prelude) { + const selectors = []; + let start = 0; + let bracket = 0; + let paren = 0; + let quote = null; + for (let i = 0; i < prelude.length; i++) { + const ch = prelude[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '[') bracket++; + else if (ch === ']') bracket = Math.max(0, bracket - 1); + else if (ch === '(') paren++; + else if (ch === ')') paren = Math.max(0, paren - 1); + else if (ch === ',' && bracket === 0 && paren === 0) { + selectors.push(prelude.slice(start, i)); + start = i + 1; + } + } + selectors.push(prelude.slice(start)); + return selectors; +} + +function selectorHasVariant(selector, variantNum) { + return variantSelectorRegex(variantNum).test(selector); +} + +function variantSelectorRegex(variantNum) { + return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); +} + +function formatCssRule(selector, body) { + return `${selector} { ${body.trim()} }`; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { + const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); + const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); + const resultBase = { + file: manifest.sourceFile, + sourceFile: manifest.sourceFile, + previewMode: 'svelte-component', + componentDir: manifest.componentDir, + carbonize: false, + }; + if (!fs.existsSync(variantPath)) { + return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; + } + + const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); + if (manifest.mode === 'insert') { + return inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, + }); + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const contract = manifest.propContract || []; + const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); + const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract) + .split('\n') + .map((line) => line.trimEnd()); + + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const start = Number(manifest.sourceStartLine) - 1; + const end = Number(manifest.sourceEndLine) - 1; + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { + return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; + } + + const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, start), + ...indentedMarkup, + ...sourceLines.slice(end + 1), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, +}) { + if (!svelteMarkupHasVisibleContent(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; + } + if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const restoredMarkup = String(markup || '') + .split('\n') + .map((line) => line.trimEnd()); + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const insertIndex = Number(manifest.insertLine) - 1; + if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { + return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; + } + + const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; + const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, insertIndex), + ...indentedMarkup, + ...sourceLines.slice(insertIndex), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function svelteMarkupHasVisibleContent(markup) { + const text = String(markup || '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 0) return true; + return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); +} + +function mergeOriginalTopLevelAttrs(markup, originalMarkup) { + const variantOpen = matchOpeningTag(markup); + const originalOpen = matchOpeningTag(originalMarkup); + if (!variantOpen || !originalOpen) return markup; + if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; + + const variantAttrs = parseAttrSegments(variantOpen.attrs); + const originalAttrs = parseAttrSegments(originalOpen.attrs); + const additions = []; + let attrs = variantOpen.attrs; + + const originalClass = originalAttrs.get('class'); + const variantClass = variantAttrs.get('class'); + if (originalClass && variantClass) { + const merged = mergeStaticClassAttr(originalClass, variantClass); + if (merged) { + attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); + variantAttrs.set('class', { ...variantClass, raw: merged }); + } + } else if (originalClass && !variantClass) { + additions.push(originalClass.raw); + } + + for (const [name, attr] of originalAttrs) { + if (name === 'class') continue; + if (!variantAttrs.has(name)) additions.push(attr.raw); + } + + if (additions.length === 0 && attrs === variantOpen.attrs) return markup; + const nextOpen = variantOpen.prefix + + variantOpen.tag + + attrs + + additions.map((attr) => ' ' + attr.trim()).join('') + + variantOpen.close; + return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); +} + +function matchOpeningTag(markup) { + const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); + if (!match) return null; + return { + raw: match[0], + prefix: match[1], + tag: match[2], + attrs: match[3] || '', + close: match[4], + index: match.index || 0, + }; +} + +function parseAttrSegments(attrs) { + const out = new Map(); + const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; + let match; + while ((match = re.exec(attrs))) { + const raw = match[0]; + const name = match[1]; + out.set(name, { + name, + raw, + start: match.index, + end: match.index + raw.length, + }); + } + return out; +} + +function mergeStaticClassAttr(originalClass, variantClass) { + const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + if (!originalValue || !variantValue) return null; + const quote = variantValue[1]; + const classes = [ + ...variantValue[2].split(/\s+/), + ...originalValue[2].split(/\s+/), + ].filter(Boolean); + return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; +} + +export function removeSvelteComponentSession(id, cwd = process.cwd()) { + const dir = componentSessionDir(id, cwd); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* non-fatal */ } +} + +export function removeAllSvelteComponentSessions(cwd = process.cwd()) { + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('__')) continue; + try { + fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + } catch { /* non-fatal */ } + } +} + +export function deferredAcceptsPath(cwd = process.cwd()) { + const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16); + return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json'); +} + +export function readDeferredAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch { + return { accepts: [] }; + } +} + +export function writeDeferredAccept(entry, cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const data = readDeferredAccepts(cwd); + data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id); + data.accepts.push({ ...entry, createdAt: new Date().toISOString() }); + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + const data = readDeferredAccepts(cwd); + const pending = Array.isArray(data.accepts) ? data.accepts : []; + const results = []; + const remaining = []; + for (const entry of pending) { + try { + const manifest = findSvelteComponentManifest(entry.id, cwd); + if (!manifest) { + results.push({ id: entry.id, ok: false, error: 'manifest not found' }); + remaining.push(entry); + continue; + } + const result = inlineSvelteComponentAccept( + manifest, + entry.variantNum, + entry.paramValues || null, + cwd, + ); + results.push({ id: entry.id, ok: result.handled !== false, result }); + if (result.handled === false) remaining.push(entry); + } catch (err) { + results.push({ id: entry.id, ok: false, error: err.message }); + remaining.push(entry); + } + } + if (remaining.length > 0) { + fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8'); + } else { + try { fs.rmSync(file, { force: true }); } catch {} + } + return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results }; +} + +export function buildSvelteComponentCssAuthoring(count) { + const variantNumbers = Array.from({ length: count }, (_, i) => i + 1); + return { + mode: 'svelte-component', + styleTag: null, + strategy: 'component-style-block', + rulePattern: '.semantic-class { ... }', + selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'), + requirements: [ + 'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).', + 'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.', + 'Put variant CSS in the component ' : '')); + if (paramValues && Object.keys(paramValues).length > 0) { + lines.push( + bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close, + ); + } + lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + lines.push(bodyIndent + '
'); + lines.push(...bodyRestored); + lines.push(bodyIndent + '
'); + }; + + if (isJsx) { + const wrapperStyle = 'style={{ display: "contents" }}'; + lines.push(indent + '
'); + pushCarbonizeBody(indent + ' '); + lines.push(indent + '
'); + } else { + pushCarbonizeBody(indent); + } + + return lines; +} + +function reindentContent(contentLines, fromIndent, toIndent) { + return contentLines.map((line) => { + if (line.trim() === '') return ''; + if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length); + return toIndent + line.trimStart(); + }); +} + function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; @@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const needsCarbonize = !!(cssContent || hasHelperAttrs); - // Build the replacement const restored = deindentContent(variantContent, indent); - const replacement = []; - - if (cssContent) { - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); - // JSX targets need the CSS body wrapped in a template literal so that the - // `{` and `}` in CSS rules don't get parsed as JSX expressions. - replacement.push(indent + '' : '')); - if (paramValues && Object.keys(paramValues).length > 0) { - // Preserve the user's knob positions for the carbonize-cleanup agent - // to bake into the final CSS when it collapses scoped rules. - replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); - } - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); - } - - // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the - // carbonize CSS block working visually by re-wrapping the accepted content - // in a data-impeccable-variant="N" div with `display: contents` (so layout - // isn't affected). The carbonize agent strips this attribute + wrapper when - // it moves the CSS to a proper stylesheet. - // - // Style attribute syntax has to follow the host file's flavor — JSX files - // need the object form, otherwise React 19 throws "Failed to set indexed - // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. - if (cssContent) { - const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; - replacement.push(indent + '
'); - replacement.push(...restored); - replacement.push(indent + '
'); - } else { - replacement.push(...restored); - } + const replacement = buildCarbonizeReplacement({ + indent, + commentSyntax, + isJsx, + id, + variantNum, + cssContent, + paramValues, + restored, + }); const newLines = [ ...lines.slice(0, replaceRange.start), @@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; } +function readSourceShadowPreviewMeta(content, id) { + const escaped = escapeRegExp(id); + const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>'); + const match = String(content || '').match(wrapperRe); + if (!match) return null; + const tag = match[0]; + if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null; + const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file'); + const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start')); + const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end')); + if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null; + return { sourceFile, sourceStartLine, sourceEndLine }; +} + +function readHtmlAttr(tag, name) { + const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1')); + if (!match) return null; + return decodeHtmlAttr(match[2]); +} + +function decodeHtmlAttr(value) { + return String(value || '') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- @@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts }; diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index f609ac847..d7c580e2f 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -26,9 +26,9 @@ return; } - // --------------------------------------------------------------------------- + // // Design tokens - // --------------------------------------------------------------------------- + // // Brand kinpaku (gold) is pinned to the site's neo-kinpaku tokens // (see site/styles/kinpaku-tokens.css) so Accept / knobs / cycle-dots / @@ -115,19 +115,48 @@ { value: 'overdrive', label: 'Overdrive' }, ]; - // --------------------------------------------------------------------------- + const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions']; + const LIVE_UI_SURFACES = [ + { 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'] }, + { key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] }, + { key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-configure-input-wrap', PREFIX + '-input', PREFIX + '-configure-voice'] }, + { key: 'action-picker', ids: [PREFIX + '-picker'] }, + { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, + { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, + { key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] }, + { key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] }, + { key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] }, + { 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'] }, + { key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] }, + { key: 'design-system-panel', ids: [PREFIX + '-design-host'] }, + { key: 'toasts-and-errors', ids: [PREFIX + '-toast'] }, + { key: 'css-isolation-boundary', ids: [PREFIX + '-root'] }, + ]; + const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))]; + + // // State - // --------------------------------------------------------------------------- + // let state = 'IDLE'; let hoveredElement = null; let selectedElement = null; let currentSessionId = null; - let pendingAcceptedSession = null; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; + let svelteComponentSession = null; + let svelteRuntimePromise = null; + let pendingSvelteComponentRetryObserver = null; + let currentSourceFile = null; + let currentPreviewFile = null; + let currentPreviewMode = null; + let recoveryWaitingForAnchor = false; + let pendingAcceptedSession = null; let variantObserver = null; + let variantSelectionInFlight = false; + let variantSelectionPromise = null; + let recoveringEmptyCycling = false; let hasProjectContext = false; let selectedAction = 'impeccable'; let selectedCount = 3; @@ -175,14 +204,17 @@ let highlightEl = null; let tooltipEl = null; let barEl = null; + let barHideSeq = 0; let pickerEl = null; let toastEl = null; let scrollRaf = null; let editBadgeEl = null; + let editBadgeProxyRoot = null; + let editBadgeProxyByTarget = new Map(); - // --------------------------------------------------------------------------- + // // Helpers - // --------------------------------------------------------------------------- + // function own(el) { return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); @@ -204,8 +236,105 @@ return s; } + function rectIsUsableAnchor(rect) { + return !!rect && rect.width > 0.5 && rect.height > 0.5; + } + + function makeFrozenAnchor(el) { + if (!el || !el.getBoundingClientRect) return null; + const r = el.getBoundingClientRect(); + if (!rectIsUsableAnchor(r)) return null; + const rect = { + x: r.x, y: r.y, + top: r.top, left: r.left, + right: r.right, bottom: r.bottom, + width: r.width, height: r.height, + }; + return { + __impeccableFrozenAnchor: true, + tagName: el.tagName || 'DIV', + id: el.id || '', + classList: el.classList ? [...el.classList] : [], + hasAttribute: () => false, + getBoundingClientRect: () => rect, + }; + } + function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } + function cssId(id) { + if (window.CSS?.escape) return CSS.escape(id); + return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); + } + + function liveUiRoot() { + const root = window.__IMPECCABLE_LIVE_UI_ROOT__; + if (root && typeof root.appendChild === 'function') return root; + return document.body; + } + + function uiAppend(el) { + liveUiRoot().appendChild(el); + return el; + } + + function uiAppendStyle(styleEl) { + const root = liveUiRoot(); + if (root && root !== document.body) root.appendChild(styleEl); + else document.head.appendChild(styleEl); + return styleEl; + } + + function uiGetById(id) { + const root = liveUiRoot(); + if (root?.getElementById) { + const found = root.getElementById(id); + if (found) return found; + } + if (root?.querySelector) { + const found = root.querySelector('#' + cssId(id)); + if (found) return found; + } + return document.getElementById(id); + } + + function activeElementDeep() { + let active = document.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; + return active; + } + + window.__IMPECCABLE_LIVE_CHROME_CORE__ = { + version: 1, + adapter: window.__IMPECCABLE_LIVE_ADAPTER__ || 'dom', + mountContract: LIVE_CHROME_MOUNT_CONTRACT, + surfaces: LIVE_UI_SURFACES, + componentIds: LIVE_UI_COMPONENT_IDS, + root: liveUiRoot, + append: uiAppend, + appendStyle: uiAppendStyle, + getById: uiGetById, + activeElementDeep, + debugState: () => ({ + state, + currentSessionId, + expectedVariants, + arrivedVariants, + visibleVariant, + savedSession: loadSession(), + sourceFile: currentSourceFile, + previewFile: currentPreviewFile, + previewMode: currentPreviewMode, + barText: barEl?.textContent || null, + barConnected: !!barEl?.isConnected, + hasSvelteComponentSession: !!svelteComponentSession, + mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0, + pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver, + recoveryWaitingForAnchor, + evtSourceReadyState: evtSource ? evtSource.readyState : null, + }), + }; + // Modal-aware chrome: keep our floating UI clickable inside Radix / // Headless UI / vaul portals. // @@ -245,9 +374,9 @@ rootEl.addEventListener('focusin', stop); } - // --------------------------------------------------------------------------- + // // Highlight overlay - // --------------------------------------------------------------------------- + // function initHighlight() { highlightEl = document.createElement('div'); @@ -259,7 +388,7 @@ transition: HIGHLIGHT_TRANSITION, display: 'none', opacity: '0', }); - document.body.appendChild(highlightEl); + uiAppend(highlightEl); tooltipEl = document.createElement('div'); tooltipEl.id = PREFIX + '-tooltip'; @@ -273,7 +402,7 @@ letterSpacing: '0.02em', transition: TOOLTIP_TRANSITION, }); - document.body.appendChild(tooltipEl); + uiAppend(tooltipEl); } function showHighlight(el) { @@ -310,7 +439,7 @@ if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; } } - // --------------------------------------------------------------------------- + // // Annotation overlay (comment pins + kinpaku strokes) // // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned @@ -318,7 +447,7 @@ // drag) drops a comment pin; drag paints a kinpaku SVG stroke. All coords // are stored in element-local CSS px so they survive scroll / resize and // correlate directly with the captured PNG. - // --------------------------------------------------------------------------- + // const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it @@ -397,7 +526,7 @@ annotOverlayEl.addEventListener('pointermove', onAnnotMove); annotOverlayEl.addEventListener('pointerup', onAnnotUp); annotOverlayEl.addEventListener('pointercancel', onAnnotUp); - document.body.appendChild(annotOverlayEl); + uiAppend(annotOverlayEl); // Modal-host friendliness: pointer-events is already 'auto' on this // overlay; we only need to silence the host's outside-interaction // listeners. Don't override pointer-events here (the overlay toggles @@ -828,9 +957,9 @@ return wrap; } - // --------------------------------------------------------------------------- + // // Element context extraction - // --------------------------------------------------------------------------- + // function stripManualEditRuntimeState(root) { if (!root || root.nodeType !== 1) return; @@ -971,9 +1100,9 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } - // --------------------------------------------------------------------------- + // // The Bar - one floating element, three modes - // --------------------------------------------------------------------------- + // // Contextual-bar palette. Cached at init so every build*Row reads a // consistent set of colors; detectPageTheme runs once rather than on every @@ -1006,7 +1135,7 @@ padding: '6px', maxWidth: '520px', minWidth: '320px', }); - document.body.appendChild(barEl); + uiAppend(barEl); defangOutsideHandlers(barEl); } @@ -1041,6 +1170,8 @@ } function showBar(mode) { + barHideSeq += 1; + if (mode === 'cycling' && !ensureCyclingRenderable('show-bar')) return; barEl.innerHTML = ''; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); @@ -1058,11 +1189,12 @@ function hideBar() { if (!barEl) return; + const hideSeq = ++barHideSeq; stopVoice({ suppressSubmit: true }); if (configureKind === 'insert') clearInsertPicking(); barEl.style.opacity = '0'; barEl.style.transform = 'translateY(6px)'; - setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); + setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); if (state === 'EDITING') restoreInlineEditDrafts(); @@ -1071,6 +1203,7 @@ function updateBarContent(mode) { if (!barEl || barEl.style.display === 'none') return; + if (mode === 'cycling' && !ensureCyclingRenderable('update-bar')) return; barEl.innerHTML = ''; // Reset bar styling to the kinpaku picker palette barEl.style.background = BP.surface; @@ -1090,13 +1223,13 @@ syncPageChatFocus('update-bar-content'); } - // --- Configure row --- + // Configure row function syncConfigureInputChrome() { - const wrap = document.getElementById(PREFIX + '-configure-input-wrap'); - const input = document.getElementById(PREFIX + '-input'); + const wrap = uiGetById(PREFIX + '-configure-input-wrap'); + const input = uiGetById(PREFIX + '-input'); if (!wrap || !input) return; - const focused = document.activeElement === input; + const focused = activeElementDeep() === input; wrap.dataset.inputFocused = focused ? 'true' : 'false'; wrap.dataset.voiceListening = (voiceListening && voiceCtx?.mode === 'configure') ? 'true' : 'false'; wrap.style.borderColor = (voiceListening && voiceCtx?.mode === 'configure') @@ -1104,7 +1237,7 @@ : (focused ? BP.accentSoft : BP.hairline); } - // --- Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) --- + // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) function detectInsertAxisFromStyle(style) { const display = style?.display || 'block'; @@ -1377,7 +1510,7 @@ display: 'none', opacity: '0.9', }); - document.body.appendChild(insertLineEl); + uiAppend(insertLineEl); defangOutsideHandlers(insertLineEl); return insertLineEl; } @@ -1440,6 +1573,10 @@ /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { + if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor) return anchor; + } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (wrapper) { @@ -1557,6 +1694,11 @@ positionBar(); } + function showOrUpdateCyclingBar() { + if (barEl && barEl.style.display !== 'none') updateBarContent('cycling'); + else showBar('cycling'); + } + function buildPlaceholderResizeHandles() { if (!placeholderResizeLayerEl) return; placeholderResizeLayerEl.innerHTML = ''; @@ -1665,7 +1807,7 @@ } function isInsertCreateEnabled(btn) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); + btn = btn || uiGetById(PREFIX + '-insert-create'); return !!btn && btn.getAttribute('aria-disabled') !== 'true'; } @@ -1691,7 +1833,7 @@ lineHeight: '1.35', }); insertCreateTooltipEl.id = PREFIX + '-insert-create-tooltip'; - document.body.appendChild(insertCreateTooltipEl); + uiAppend(insertCreateTooltipEl); return insertCreateTooltipEl; } @@ -1723,8 +1865,8 @@ } function syncInsertCreateButton(btn, input) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); - input = input || document.getElementById(PREFIX + '-insert-input'); + btn = btn || uiGetById(PREFIX + '-insert-create'); + input = input || uiGetById(PREFIX + '-insert-input'); if (!btn || !input) return; const gate = insertCreateGateState(input); const ok = canCreateInsert(gate); @@ -1833,7 +1975,7 @@ voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!document.getElementById(PREFIX + '-configure-input-style')) { + if (!uiGetById(PREFIX + '-configure-input-style')) { const s = document.createElement('style'); s.id = PREFIX + '-configure-input-style'; s.textContent = @@ -1842,7 +1984,7 @@ '#' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: impeccable-configure-voice-pulse 1.1s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-configure-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } input.addEventListener('focus', () => syncConfigureInputChrome()); @@ -1949,6 +2091,9 @@ transition: 'border-color 0.15s ease', }); inputWrap.id = PREFIX + '-insert-input-wrap'; + inputWrap.addEventListener('pointerdown', (e) => e.stopPropagation()); + inputWrap.addEventListener('mousedown', (e) => e.stopPropagation()); + inputWrap.addEventListener('click', (e) => e.stopPropagation()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; @@ -1984,6 +2129,12 @@ voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; input.addEventListener('input', () => syncInsertCreateButton()); + input.addEventListener('pointerdown', (e) => e.stopPropagation()); + input.addEventListener('mousedown', (e) => e.stopPropagation()); + input.addEventListener('click', (e) => { + e.stopPropagation(); + try { input.focus({ preventScroll: true }); } catch { input.focus(); } + }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); @@ -2049,6 +2200,7 @@ }); create.addEventListener('mouseleave', hideInsertCreateTooltip); create.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); if (controlsLocked) { showManualApplyBusyToast(); return; } if (!isInsertCreateEnabled(create)) return; @@ -2060,7 +2212,7 @@ return row; } - // --- Generating row --- + // Generating row function buildGeneratingRow() { const row = el('div', { @@ -2086,19 +2238,24 @@ }); // Variants currently arrive atomically in a single file edit, so a // per-variant counter would lie. Say what's true. - status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + expectedVariants + ' variants...' - : 'Done'; + status.textContent = recoveryWaitingForAnchor + ? 'Variants ready. Reveal the selected element to resume.' + : (arrivedVariants < expectedVariants + ? 'Generating ' + expectedVariants + ' variants...' + : 'Done'); row.appendChild(status); return row; } - // --- Cycling row --- + // Cycling row const TUNE_ICON_SVG = ''; function buildCyclingRow() { + if (!ensureCyclingRenderable('build-cycling-row')) { + return el('div', { display: 'none' }); + } const row = el('div', { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 2px', @@ -2106,6 +2263,7 @@ // Prev const prev = navBtn('\u2190'); + prev.id = PREFIX + '-variant-prev'; prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); }); if (visibleVariant <= 1) prev.style.opacity = '0.3'; row.appendChild(prev); @@ -2118,11 +2276,13 @@ fontFamily: MONO, fontSize: '11px', fontWeight: '500', color: BP.textDim, minWidth: '24px', textAlign: 'center', }); + counter.id = PREFIX + '-variant-counter'; counter.textContent = visibleVariant + '/' + arrivedVariants; row.appendChild(counter); // Next const next = navBtn('\u2192'); + next.id = PREFIX + '-variant-next'; next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); }); if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3'; row.appendChild(next); @@ -2208,9 +2368,9 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders - // --- Saving row (waiting for agent to process accept/discard) --- + // Saving row (waiting for agent to process accept/discard) function buildSavingRow() { const row = el('div', { @@ -2235,7 +2395,7 @@ return row; } - // --- Confirmed row (green success, auto-dismisses) --- + // Confirmed row (green success, auto-dismisses) function buildConfirmedRow() { const row = el('div', { @@ -2256,7 +2416,7 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders function buildDots(clickable) { const container = el('div', { @@ -2290,10 +2450,7 @@ const idx = i; dot.addEventListener('click', (e) => { e.stopPropagation(); - visibleVariant = idx; - showVariantInDOM(currentSessionId, idx); - updateSelectedElement(); - updateBarContent('cycling'); + selectVariant(idx, 'variant_changed'); }); } container.appendChild(dot); @@ -2323,13 +2480,14 @@ function el(tag, styles) { const e = document.createElement(tag); + if (String(tag).toLowerCase() === 'button') e.type = 'button'; if (styles) Object.assign(e.style, styles); return e; } - // --------------------------------------------------------------------------- + // // Action picker popover - // --------------------------------------------------------------------------- + // function initActionPicker() { const P = barPaletteForTheme(detectPageTheme()); @@ -2384,16 +2542,20 @@ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent'; }); chip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + const prompt = uiGetById(PREFIX + '-input')?.value || ''; selectedAction = action.value; hideActionPicker(); updateBarContent('configure'); + const input = uiGetById(PREFIX + '-input'); + if (input && prompt) input.value = prompt; }); grid.appendChild(chip); }); pickerEl.appendChild(grid); - document.body.appendChild(pickerEl); + uiAppend(pickerEl); defangOutsideHandlers(pickerEl); // Cache the palette on the picker so toggleActionPicker's state refresh @@ -2433,7 +2595,33 @@ setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180); } - // --------------------------------------------------------------------------- + function ensureCyclingRenderable(reason) { + if (arrivedVariants > 0) { + if (visibleVariant < 1 || visibleVariant > arrivedVariants) visibleVariant = 1; + return true; + } + recoverEmptyCycling(reason); + return false; + } + + function recoverEmptyCycling(reason) { + if (recoveringEmptyCycling) return; + recoveringEmptyCycling = true; + try { + console.warn('[impeccable] Refusing to render empty variant cycling state:', reason); + const message = 'No variants were mounted. Please try again.'; + if (svelteComponentSession?.sessionId === currentSessionId) { + abortSvelteComponentInjection(currentSessionId, message); + return; + } + cleanup(); + showToast(message, 5000); + } finally { + recoveringEmptyCycling = false; + } + } + + // // Params panel (per-variant coarse controls) // // Variants may declare a parameter manifest via a JSON attribute on the @@ -2446,13 +2634,13 @@ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped // CSS can respond instantly without regeneration: // - // range / numeric toggle → CSS var (`--p-`) used via var(--p-foo, N) + // range / numeric toggle -> CSS custom property used by variant styles // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."] // // On variant switch, values reset to that variant's declared defaults. // On accept, current values are sent in the event payload so the agent // can bake them into the source-file write. - // --------------------------------------------------------------------------- + // let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide) let paramsPanelInner = null; // translating content (carries bg, padding, knobs) @@ -2507,7 +2695,7 @@ }); paramsPanelEl.appendChild(paramsPanelBody); - document.body.appendChild(paramsPanelEl); + uiAppend(paramsPanelEl); // Don't override pointer-events: the panel toggles between 'none' (closed, // click-through) and 'auto' (open) on its own. Just silence the host's // outside-interaction listeners while the panel is open. @@ -2516,14 +2704,40 @@ } + function getMountedSvelteComponentAnchor(session = svelteComponentSession) { + const el = session?.mountTargetEl?.firstElementChild || null; + if (!el || !document.body.contains(el)) return null; + return rectIsUsableAnchor(el.getBoundingClientRect()) ? el : null; + } + + function resolveSvelteComponentAnchor(session = svelteComponentSession) { + return getMountedSvelteComponentAnchor(session) + || session?.swapAnchor + || null; + } + function getVisibleVariantEl() { if (!currentSessionId) return null; + if (svelteComponentSession?.sessionId === currentSessionId) { + return resolveSvelteComponentAnchor() + || svelteComponentSession.wrapperEl + || null; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } function parseVariantParams(variantEl) { + // Svelte component variants can't carry a `data-impeccable-params` attribute: + // the compiler reads `{` inside attribute values as expression delimiters, so + // JSON-with-braces breaks the build. For that path the params live in a sidecar + // params.json keyed by variant number, loaded into the session at mount time. + if (svelteComponentSession?.sessionId === currentSessionId) { + const byVariant = svelteComponentSession.paramsByVariant || {}; + const params = byVariant[String(visibleVariant)] || byVariant[visibleVariant]; + return Array.isArray(params) ? params : []; + } if (!variantEl) return []; const raw = variantEl.getAttribute('data-impeccable-params'); if (!raw) return []; @@ -2685,11 +2899,11 @@ } } - // --------------------------------------------------------------------------- + // // Inline text editing - makes pure-text descendants of the picked element // directly contenteditable. Save stages copy edits in the live buffer; the // Apply copy edits dock later asks the AI to apply the staged batch. - // --------------------------------------------------------------------------- + // let inlineEditRows = []; let inlineEditDrafts = new Map(); @@ -2803,7 +3017,7 @@ function disableInlineEdit(opts = {}) { for (const row of inlineEditRows) { - if (document.activeElement === row.el) row.el.blur(); + if (activeElementDeep() === row.el) row.el.blur(); row.el.removeAttribute('contenteditable'); delete row.el.dataset.impeccableEditable; delete row.el.dataset.impeccableOriginalText; @@ -3133,7 +3347,7 @@ if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) { showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); } else { - showToast('Save failed: retry or cancel', 4000); + showToast('Save failed - retry or cancel', 4000); } } } @@ -3181,11 +3395,11 @@ } function ensureSpinKeyframes() { - if (document.getElementById(PREFIX + '-keyframes')) return; + if (uiGetById(PREFIX + '-keyframes')) return; const style = document.createElement('style'); style.id = PREFIX + '-keyframes'; style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }'; - document.head.appendChild(style); + uiAppendStyle(style); } function pendingApplyLabel(count) { @@ -3318,10 +3532,10 @@ closeTunePopover(); } if (barEl && barEl.style.display !== 'none' && state === 'CONFIGURING') { - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value : ''; updateBarContent('configure'); - const nextInput = document.getElementById(PREFIX + '-input'); + const nextInput = uiGetById(PREFIX + '-input'); if (nextInput) nextInput.value = prompt; } if (editBadgeEl && editBadgeEl.style.display !== 'none') { @@ -3455,19 +3669,19 @@ updatePendingCounter(remaining); if (result.failed && result.failed.length > 0) { console.warn('[impeccable] some copy edits failed:', result.failed); - showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed - see console', 5000); } else { const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0); if (n > 0) { showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); } else { console.warn('[impeccable] apply returned no verified edits:', result); - showToast('No edits applied, see console', 4000); + showToast('No edits applied - see console', 4000); } } } catch (err) { console.error('[impeccable] commit failed:', err); - showToast('Apply failed, see console', 4000); + showToast('Apply failed - see console', 4000); } finally { if (waitForSseCompletion) return; const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0; @@ -3497,7 +3711,7 @@ } } catch (err) { console.error('[impeccable] discard failed:', err); - showToast('Discard failed, see console', 4000); + showToast('Discard failed - see console', 4000); } } @@ -3645,7 +3859,7 @@ const failedCount = numberOrNull(msg.failedCount) || 0; const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0; if (failedCount > 0) { - showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000); + showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed - see console', 5000); } else if (appliedCount > 0) { showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500); } @@ -3799,9 +4013,164 @@ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); } - // --------------------------------------------------------------------------- + // // Edit content badge - floating button at element top-right to enter EDITING mode - // --------------------------------------------------------------------------- + // + + function usesShadowChromeRoot() { + const root = liveUiRoot(); + return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; + } + + function setImportantStyle(el, name, value) { + el.style.setProperty(name, value, 'important'); + } + + function initEditBadgeHitProxies() { + if (!usesShadowChromeRoot() || editBadgeProxyRoot) return; + editBadgeProxyRoot = document.createElement('div'); + editBadgeProxyRoot.id = PREFIX + '-edit-badge-hit-proxies'; + editBadgeProxyRoot.setAttribute('aria-hidden', 'true'); + const styles = { + all: 'initial', + position: 'fixed', + inset: '0', + width: '100vw', + height: '100vh', + zIndex: String(Z.toast + 1), + pointerEvents: 'none', + background: 'transparent', + overflow: 'visible', + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + document.body.appendChild(editBadgeProxyRoot); + } + + function styleEditBadgeProxy(proxy, target) { + const rect = target.getBoundingClientRect(); + const cursor = getComputedStyle(target).cursor || 'pointer'; + const styles = { + all: 'initial', + position: 'fixed', + left: rect.left + 'px', + top: rect.top + 'px', + width: rect.width + 'px', + height: rect.height + 'px', + margin: '0', + padding: '0', + border: '0', + borderRadius: '0', + background: 'transparent', + color: 'transparent', + opacity: '0.001', + pointerEvents: 'auto', + cursor, + zIndex: String(Z.toast + 2), + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(proxy, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + } + + function proxyMouseEvent(type, source, target) { + let event; + try { + event = new MouseEvent(type, { + bubbles: type !== 'mouseenter' && type !== 'mouseleave', + cancelable: true, + composed: true, + clientX: source.clientX, + clientY: source.clientY, + screenX: source.screenX, + screenY: source.screenY, + button: source.button || 0, + buttons: source.buttons || 0, + ctrlKey: source.ctrlKey, + metaKey: source.metaKey, + shiftKey: source.shiftKey, + altKey: source.altKey, + }); + target.dispatchEvent(event); + } catch {} + } + + function bindEditBadgeProxy(proxy, target) { + const stop = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + proxy.addEventListener('mouseenter', (event) => { + stop(event); + proxyMouseEvent('mouseenter', event, target); + proxyMouseEvent('mouseover', event, target); + }); + proxy.addEventListener('mouseleave', (event) => { + stop(event); + proxyMouseEvent('mouseleave', event, target); + proxyMouseEvent('mouseout', event, target); + }); + proxy.addEventListener('mousedown', (event) => { + stop(event); + target.focus?.({ preventScroll: true }); + proxyMouseEvent('mousedown', event, target); + }); + proxy.addEventListener('mouseup', (event) => { + stop(event); + proxyMouseEvent('mouseup', event, target); + }); + proxy.addEventListener('click', (event) => { + stop(event); + target.click(); + syncEditBadgeHitProxies(); + }); + } + + function editBadgeProxyTargets() { + if (!usesShadowChromeRoot() || !editBadgeEl || editBadgeEl.style.display === 'none') return []; + return [...editBadgeEl.querySelectorAll('button')].filter((target) => { + if (target.disabled) return false; + const rect = target.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return false; + const style = getComputedStyle(target); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); + } + + function syncEditBadgeHitProxies() { + if (!usesShadowChromeRoot()) { + if (editBadgeProxyRoot) editBadgeProxyRoot.remove(); + editBadgeProxyRoot = null; + editBadgeProxyByTarget = new Map(); + return; + } + initEditBadgeHitProxies(); + if (!editBadgeProxyRoot) return; + const targets = editBadgeProxyTargets(); + const active = new Set(targets); + for (const [target, proxy] of editBadgeProxyByTarget) { + if (!active.has(target) || !target.isConnected) { + proxy.remove(); + editBadgeProxyByTarget.delete(target); + } + } + for (const target of targets) { + let proxy = editBadgeProxyByTarget.get(target); + if (!proxy) { + proxy = document.createElement('button'); + proxy.type = 'button'; + proxy.tabIndex = -1; + proxy.dataset.impeccableEditBadgeProxy = 'true'; + proxy.setAttribute('aria-hidden', 'true'); + bindEditBadgeProxy(proxy, target); + editBadgeProxyRoot.appendChild(proxy); + editBadgeProxyByTarget.set(target, proxy); + } + proxy.title = target.title || target.textContent || 'Edit copy'; + styleEditBadgeProxy(proxy, target); + } + } function initEditBadge() { editBadgeEl = document.createElement('div'); @@ -3813,10 +4182,11 @@ display: 'none', userSelect: 'none', }); - document.body.appendChild(editBadgeEl); + uiAppend(editBadgeEl); + initEditBadgeHitProxies(); // Remove focus rings on edit badge buttons + contenteditable elements - if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + if (!uiGetById(PREFIX + '-edit-badge-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-edit-badge-focus-style'; s.textContent = @@ -3826,21 +4196,26 @@ '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; - document.head.appendChild(s); + uiAppendStyle(s); } } function positionEditBadge() { - if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') { + syncEditBadgeHitProxies(); + return; + } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { if (editBadgeEl) editBadgeEl.style.display = 'none'; + syncEditBadgeHitProxies(); return; } editBadgeEl.style.display = 'flex'; @@ -4047,7 +4422,7 @@ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN; } // Re-render the bar so the Tune chip picks up the active styling. - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } function closeTunePopover() { @@ -4055,13 +4430,13 @@ hideParamsPanel(); if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT; if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') { - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } } - // --------------------------------------------------------------------------- + // // Variant cycling in DOM - // --------------------------------------------------------------------------- + // function isVariantShown(el) { if (!el) return false; @@ -4081,9 +4456,42 @@ } } - function showVariantInDOM(sessionId, num) { + function scheduleCyclingBarSync(sessionId, variantNum) { + requestAnimationFrame(() => { + if (state !== 'CYCLING') return; + if (currentSessionId !== sessionId) return; + if (visibleVariant !== variantNum) return; + showOrUpdateCyclingBar(); + syncCyclingControls(); + positionBar(); + }); + } + + function syncCyclingControls() { + const shown = svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0 + ? svelteComponentSession.mountedVariant + : visibleVariant; + const counter = uiGetById(PREFIX + '-variant-counter'); + if (counter && arrivedVariants > 0) counter.textContent = shown + '/' + arrivedVariants; + const prev = uiGetById(PREFIX + '-variant-prev'); + const next = uiGetById(PREFIX + '-variant-next'); + if (prev) prev.style.opacity = shown <= 1 ? '0.3' : '1'; + if (next) next.style.opacity = shown >= arrivedVariants ? '0.3' : '1'; + if (currentSessionId && state === 'CYCLING') saveSession(); + } + + async function showVariantInDOM(sessionId, num) { + if (svelteComponentSession?.sessionId === sessionId) { + visibleVariant = num; + const mounted = await mountSvelteComponentVariant(num); + if (!mounted) return false; + updateSelectedElement(); + refreshParamsPanel(); + scheduleCyclingBarSync(sessionId, num); + return true; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); - if (!wrapper) return; + if (!wrapper) return false; for (const child of wrapper.children) { const v = child.dataset ? child.dataset.impeccableVariant : null; if (!v) continue; @@ -4093,6 +4501,378 @@ // CYCLING yet, the subsequent CYCLING transition triggers its own // refresh) and every cycle step. refreshParamsPanel(); + return true; + } + + function isSvelteComponentManifestPath(filePath) { + return String(filePath || '').endsWith('manifest.json'); + } + + function parseOriginalMarkupElement(originalMarkup) { + const parser = new DOMParser(); + const doc = parser.parseFromString('
' + originalMarkup + '
', 'text/html'); + return doc.getElementById('impeccable-anchor')?.firstElementChild || null; + } + + function findLiveElementForOriginalMarkup(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const tag = origContent.tagName.toLowerCase(); + const cls = origContent.className; + let liveEl = null; + if (origContent.id) { + liveEl = document.getElementById(origContent.id); + } else if (cls) { + const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + for (const c of candidates) { + if (c.className === cls && !own(c)) { liveEl = c; break; } + } + if (!liveEl) { + const expectedClasses = String(cls).split(/\s+/).filter(Boolean); + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } + } + } + return liveEl; + } + + function isSvelteInsertManifest(manifest) { + return manifest?.previewMode === 'svelte-component' && manifest?.mode === 'insert'; + } + + function findLiveElementForSvelteManifest(manifest) { + if (isSvelteInsertManifest(manifest)) { + const anchor = findInsertAnchorInDom(); + if (anchor?.parentElement) return anchor; + } + return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function loadSvelteRuntime(runtimeModule) { + const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js'; + const url = new URL(modulePath, location.origin).href; + if (!svelteRuntimePromise) { + svelteRuntimePromise = import(/* @vite-ignore */ url); + } + return svelteRuntimePromise; + } + + // Svelte component variants declare their params in a sidecar params.json under + // componentDir (keyed by variant number), because a `data-impeccable-params` + // attribute with JSON braces can't survive the Svelte compiler. Returns a map of + // { "1": [...params], "2": [...] }; an empty object when the agent declared none. + async function loadSvelteComponentParams(manifest) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir) return {}; + const paramsPath = dir + '/params.json'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(paramsPath); + try { + const res = await fetch(url); + if (!res.ok) return {}; + const parsed = JSON.parse(await res.text()); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out = {}; + for (const [key, value] of Object.entries(parsed)) { + if (Array.isArray(value)) out[String(key)] = value; + } + return out; + } catch { + return {}; + } + } + + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { + const contract = manifest?.propContract || []; + const values = {}; + if (!liveEl || contract.length === 0) return values; + const sourceOriginal = parseOriginalMarkupElement(manifest.originalMarkup || ''); + if (!sourceOriginal) return values; + const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl); + for (const entry of contract) { + const token = '{' + entry.expr + '}'; + values[entry.prop] = map.get(token) || ''; + } + return values; + } + + async function mountSvelteComponentVariant(variantNum) { + if (!svelteComponentSession || !variantNum) return false; + const { manifest, mountTargetEl, sessionId } = svelteComponentSession; + try { + const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement; + svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null; + const runtime = await loadSvelteRuntime(manifest.runtimeModule); + const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte'; + const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now(); + const mod = await import(/* @vite-ignore */ moduleUrl); + const Component = mod.default; + if (svelteComponentSession.mountedInstance && runtime.unmount) { + await runtime.unmount(svelteComponentSession.mountedInstance); + svelteComponentSession.mountedInstance = null; + } + svelteComponentSession.mountedInstance = runtime.mount(Component, { + target: mountTargetEl, + props: { ...svelteComponentSession.propValues }, + intro: false, + }); + svelteComponentSession.mountedVariant = variantNum; + svelteComponentSession.runtime = runtime; + if (state === 'CYCLING') syncCyclingControls(); + const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (nextAnchor) { + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(nextAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = nextAnchor; + } else { + requestAnimationFrame(() => { + if (svelteComponentSession?.sessionId !== sessionId) return; + const settledAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!settledAnchor) return; + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(settledAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = settledAnchor; + }); + } + return true; + } catch (err) { + if (svelteComponentSession?.sessionId === sessionId) { + svelteComponentSession.swapAnchor = null; + } + console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err); + return false; + } + } + + function teardownSvelteComponentSession(restoreOriginal) { + if (!svelteComponentSession) return; + const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + if (restoreOriginal && detachedOriginal && wrapperEl?.parentElement) { + wrapperEl.parentElement.replaceChild(detachedOriginal, wrapperEl); + } else if (wrapperEl?.parentElement) { + wrapperEl.remove(); + } + svelteComponentSession = null; + svelteRuntimePromise = null; + } + + function applyOriginalAttrsToSvelteAnchor(el, originalMarkup) { + if (!el || !originalMarkup) return; + const original = parseOriginalMarkupElement(originalMarkup); + if (!original || original.tagName !== el.tagName) return; + for (const attr of original.attributes) { + if (attr.name === 'class') { + for (const className of attr.value.split(/\s+/).filter(Boolean)) { + el.classList.add(className); + } + } else if (!el.hasAttribute(attr.name)) { + el.setAttribute(attr.name, attr.value); + } + } + } + + function commitAcceptedSvelteComponentToDom(sessionId) { + if (!svelteComponentSession || svelteComponentSession.sessionId !== sessionId) return false; + const { wrapperEl, runtime, mountedInstance, manifest } = svelteComponentSession; + const anchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!anchor || !wrapperEl?.parentElement) return false; + const committed = anchor.cloneNode(true); + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(committed, manifest.originalMarkup || ''); + } + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + wrapperEl.parentElement.replaceChild(committed, wrapperEl); + svelteComponentSession = null; + svelteRuntimePromise = null; + selectedElement = committed; + return true; + } + + async function injectSvelteComponentsFromManifest(manifestPath, sessionId) { + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(manifestPath); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + const manifest = JSON.parse(await res.text()); + if (manifest.id !== sessionId) return; + + const paramsByVariant = await loadSvelteComponentParams(manifest); + currentSessionId = sessionId; + expectedVariants = Number(manifest.count) || expectedVariants || 1; + rememberSessionFileMeta({ + sourceFile: manifest.sourceFile, + previewFile: manifestPath, + previewMode: 'svelte-component', + }); + if (state !== 'CYCLING') state = 'GENERATING'; + + const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { + recoveryWaitingForAnchor = false; + svelteComponentSession.paramsByVariant = paramsByVariant; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; + await mountSvelteComponentVariant(visibleVariant || 1); + state = 'CYCLING'; + showOrUpdateCyclingBar(); + saveSession(); + return; + } + + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) { + console.warn('[impeccable] Could not find original element in live DOM.'); + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants + ? visibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + saveSession(); + queueCheckpoint('svelte_component_anchor_missing'); + waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return; + } + + const wrapper = document.createElement('div'); + wrapper.dataset.impeccableVariants = sessionId; + wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1); + wrapper.dataset.impeccablePreview = 'svelte-component'; + wrapper.style.display = 'contents'; + + const mountTarget = document.createElement('div'); + mountTarget.dataset.impeccableComponentMount = sessionId; + mountTarget.style.display = 'contents'; + wrapper.appendChild(mountTarget); + + const insertMode = isSvelteInsertManifest(manifest); + const detachedOriginal = insertMode ? null : liveEl; + if (insertMode) { + removeInsertPlaceholderDom(); + if (manifest.position === 'before') liveEl.parentElement.insertBefore(wrapper, liveEl); + else liveEl.parentElement.insertBefore(wrapper, liveEl.nextSibling); + } else { + liveEl.parentElement.replaceChild(wrapper, liveEl); + } + + svelteComponentSession = { + sessionId, + manifest, + insertMode, + wrapperEl: wrapper, + mountTargetEl: mountTarget, + detachedOriginal, + mountedInstance: null, + mountedVariant: 0, + runtime: null, + propValues: buildSveltePropValuesFromLiveElement(detachedOriginal, manifest), + paramsByVariant, + }; + if (pendingSvelteComponentRetryObserver) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + } + recoveryWaitingForAnchor = false; + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + + const mounted = await mountSvelteComponentVariant(visibleVariant); + if (!mounted) { + // The compiled component threw (e.g. a Svelte compile error in the + // variant file). Don't strand the bar in an empty CYCLING state; restore + // the original element and reset to PICKING so the user can retry. + abortSvelteComponentInjection(sessionId, 'A variant failed to compile. Fix the component and re-run.'); + return; + } + + selectedElement = mountTarget.firstElementChild || mountTarget; + state = 'CYCLING'; + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.'); + } catch (err) { + console.error('[impeccable] Failed to mount Svelte component variants:', err); + abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.'); + } + } + + function waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }) { + if (pendingSvelteComponentRetryObserver) pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = new MutationObserver(() => { + if (svelteComponentSession?.sessionId === sessionId) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + return; + } + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) return; + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + injectSvelteComponentsFromManifest(manifestPath, sessionId); + }); + pendingSvelteComponentRetryObserver.observe(document.body, { childList: true, subtree: true }); + } + + // Reset cleanly when a Svelte component session can't mount: tear the wrapper + // down (restoring the original element), clear persisted session state, and + // return the bar to PICKING. Avoids the stuck 0/0 CYCLING bar. + function abortSvelteComponentInjection(sessionId, message) { + try { + if (svelteComponentSession?.sessionId === sessionId) { + teardownSvelteComponentSession(true); + } else { + const orphan = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (orphan) orphan.remove(); + } + } catch (err) { + console.warn('[impeccable] Svelte component abort cleanup failed:', err); + } + hideShaderOverlay(); + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + stopScrollLock(); + clearSession(); + clearHandled(); + resetSessionFileMeta(); + currentSessionId = null; + expectedVariants = 0; + arrivedVariants = 0; + visibleVariant = 0; + selectedElement = null; + state = 'PICKING'; + hideBar(); + if (message) showToast(message, 5000); } /** @@ -4101,6 +4881,11 @@ * This works even when the dev server caches HTML (Bun, static servers). */ function injectVariantsFromSource(filePath, sessionId) { + if (isSvelteComponentManifestPath(filePath)) { + injectSvelteComponentsFromManifest(filePath, sessionId); + return; + } + rememberSessionFileMeta({ file: filePath }); const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) @@ -4119,7 +4904,7 @@ const doc = parser.parseFromString(block, 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { - console.error('[impeccable] Variant wrapper not found in source file.'); + console.warn('[impeccable] Variant wrapper not found in source file.'); return; } @@ -4134,31 +4919,31 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; - if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - } - + const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); if (!liveEl) { - console.error('[impeccable] Could not find original element in live DOM.'); + console.warn('[impeccable] Could not find original element in live DOM.'); + selectedElement = document.body; + recoveryWaitingForAnchor = true; + state = 'GENERATING'; + showBar('generating'); + saveSession(); + showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } + recoveryWaitingForAnchor = false; // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); arrivedVariants = variants.length; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + recoverEmptyCycling('source-fallback-empty'); + return; + } const saved = loadSession(); const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants @@ -4170,8 +4955,9 @@ selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4184,21 +4970,129 @@ }); } - function cycleVariant(dir) { + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { + const map = new Map(); + if (!sourceOriginal || !liveOriginal) return map; + + const sourceNodes = collectTextNodes(sourceOriginal) + .filter((node) => /\{[^{}]+\}/.test(node.nodeValue || '')); + const liveTexts = collectTextNodes(liveOriginal) + .map((node) => normalizePreviewText(node.nodeValue || '')) + .filter(Boolean); + let liveIndex = 0; + + for (const sourceNode of sourceNodes) { + const sourceText = sourceNode.nodeValue || ''; + const tokens = sourceText.match(/\{[^{}]+\}/g) || []; + if (tokens.length === 0) continue; + + const liveText = liveTexts[liveIndex++] || ''; + if (!liveText) continue; + + if (tokens.length === 1) { + const token = tokens[0]; + const normalizedSource = normalizePreviewText(sourceText); + if (normalizedSource === token) { + map.set(token, liveText); + continue; + } + + const match = liveText.match(expressionTextMatcher(sourceText, [token])); + if (match && match[1]) map.set(token, match[1].trim()); + continue; + } + + if (normalizePreviewText(sourceText) === tokens.join(' ')) { + for (const token of tokens) { + const tokenLiveText = liveTexts[liveIndex - 1] || ''; + if (tokenLiveText) map.set(token, tokenLiveText); + } + } + } + + return map; + } + + function expressionTextMatcher(sourceText, tokens) { + let pattern = '^'; + let cursor = 0; + for (const token of tokens) { + const index = sourceText.indexOf(token, cursor); + if (index === -1) continue; + pattern += escapeRegExp(sourceText.slice(cursor, index)).replace(/\s+/g, '\\s*'); + pattern += '(.*?)'; + cursor = index + token.length; + } + pattern += escapeRegExp(sourceText.slice(cursor)).replace(/\s+/g, '\\s*') + '$'; + return new RegExp(pattern); + } + + function collectTextNodes(root) { + if (!root) return []; + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + nodes.push(node); + node = walker.nextNode(); + } + return nodes; + } + + function normalizePreviewText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + async function selectVariant(next, checkpointReason) { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - const next = visibleVariant + dir; + if (variantSelectionInFlight) return; if (next < 1 || next > arrivedVariants) return; - visibleVariant = next; - showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself - updateSelectedElement(); - updateBarContent('cycling'); - positionBar(); - saveSession(); - queueCheckpoint('variant_changed'); + if (next === visibleVariant) return; + + const previous = visibleVariant; + variantSelectionInFlight = true; + const selectionPromise = (async () => { + visibleVariant = next; + showOrUpdateCyclingBar(); + saveSession(); + const shown = await showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself + if (!shown) { + visibleVariant = previous; + await showVariantInDOM(currentSessionId, previous); + showOrUpdateCyclingBar(); + saveSession(); + return; + } + updateSelectedElement(); + showOrUpdateCyclingBar(); + positionBar(); + saveSession(); + if (checkpointReason) queueCheckpoint(checkpointReason); + })(); + variantSelectionPromise = selectionPromise; + try { + await selectionPromise; + } finally { + if (variantSelectionPromise === selectionPromise) variantSelectionPromise = null; + variantSelectionInFlight = false; + } + } + + function cycleVariant(dir) { + selectVariant(visibleVariant + dir, 'variant_changed'); } function updateSelectedElement() { if (!currentSessionId) return; + if (svelteComponentSession?.sessionId === currentSessionId) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; + return; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); @@ -4206,6 +5100,9 @@ } function readVisibleVariantFromDOM(sessionId) { + if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { + return svelteComponentSession.mountedVariant; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -4341,9 +5238,9 @@ // scrollY that the next resume needs to read. } - // --------------------------------------------------------------------------- + // // MutationObserver for progressive variant reveal - // --------------------------------------------------------------------------- + // function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -4426,10 +5323,11 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); updateSelectedElement(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4445,9 +5343,9 @@ return obs; } - // --------------------------------------------------------------------------- + // // Bar scroll tracking - // --------------------------------------------------------------------------- + // function startScrollTracking() { function tick() { @@ -4483,10 +5381,10 @@ if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; } } - // --------------------------------------------------------------------------- + // // SSE (server→browser) + fetch POST (browser→server) // Zero-dependency replacement for WebSocket. - // --------------------------------------------------------------------------- + // let evtSource = null; let sseRetries = 0; @@ -4509,6 +5407,7 @@ console.log('[impeccable] Live mode connected.'); syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); + restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; syncPageChatFocus('sse-connected'); break; @@ -4531,11 +5430,12 @@ break; case 'done': if (maybeCompleteSteer(msg)) break; + rememberSessionFileMeta(msg); // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); } @@ -4557,7 +5457,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; if (state !== 'GENERATING') return; showToast( - "Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.", + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", 15000, ); }, 2000); @@ -4571,12 +5471,18 @@ // the final complete event. Keep the browser in its recoverable // saving state while the source cleanup is still in flight. break; + case 'discarded': + if (msg.id && msg.id === currentSessionId) { + markSessionHandled(); + cleanup(); + } + break; case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; state = 'CYCLING'; updateBarContent('cycling'); - showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000); + showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; } if (maybeCompleteSteer(msg)) break; @@ -4656,6 +5562,9 @@ expectedVariants, arrivedVariants, visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, paramValues: { ...paramsCurrentValues }, }; } @@ -4665,6 +5574,20 @@ return sendEvent(checkpointPayload(reason)).catch(() => null); } + function sendSteerCheckpoint(id, reason, extra) { + if (!id) return Promise.resolve(null); + return sendEvent({ + type: 'checkpoint', + id, + revision: sessionState.nextCheckpointRevision(), + owner: browserOwner, + phase: 'steer', + reason, + pageUrl: location.pathname, + ...(extra || {}), + }).catch(() => null); + } + function queueCheckpoint(reason) { if (!currentSessionId) return; if (checkpointTimer) clearTimeout(checkpointTimer); @@ -4674,9 +5597,9 @@ }, 120); } - // --------------------------------------------------------------------------- + // // Event handlers - // --------------------------------------------------------------------------- + // function handleMouseMove(e) { if (pendingApplyInFlight) return; @@ -4860,7 +5783,7 @@ // // DISABLED: quick-Go workflows pay an extra harness round trip because // prefetch + generate arrive as two events instead of one. Re-enable with - // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to + // a browser-side debounce (~800-1000ms, cancelled on Go) if we want to // resurrect this. Server validator and skill dispatch remain in place so // flipping this flag is the only change needed. const PREFETCH_ENABLED = false; @@ -4876,6 +5799,14 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + const deepActive = activeElementDeep(); + if ( + deepActive + && own(deepActive) + && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + ) { + return; + } // While a contenteditable text-leaf is focused, let the browser handle // all keys except Escape. Escape cancels the current edit (restores // original text) and blurs without saving, staying in CONFIGURING. @@ -4982,7 +5913,7 @@ if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!selectedElement || state !== 'CONFIGURING') return; stopVoice({ suppressSubmit: true }); - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; // Commit any pending pin edit BEFORE we snapshot annotations. @@ -4996,6 +5927,7 @@ expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); // Flip to GENERATING immediately so the bar morphs without waiting on // capture + upload. The event is emitted from captureAndEmit() once the @@ -5054,7 +5986,7 @@ function handleInsertCreate() { if (!placeholderElement || !insertAnchorElement || state !== 'CONFIGURING' || configureKind !== 'insert') return; - const input = document.getElementById(PREFIX + '-insert-input'); + const input = uiGetById(PREFIX + '-insert-input'); const prompt = input ? input.value.trim() : ''; if (annotEditing) finalizeEditingPin(); const snapshot = { @@ -5064,10 +5996,12 @@ if (!canCreateInsert({ prompt, comments: snapshot.comments, strokes: snapshot.strokes })) return; stopVoice({ suppressSubmit: true }); + pendingAcceptedSession = null; currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); selectedElement = placeholderElement; insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement); @@ -5107,9 +6041,9 @@ captureAndEmit(elForCapture, basePayload, snapshot, captureRect); } - // --------------------------------------------------------------------------- + // // Screenshot capture + upload - // --------------------------------------------------------------------------- + // let msLoadPromise = null; function loadModernScreenshot() { @@ -5120,7 +6054,7 @@ s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; s.onload = () => resolve(window.modernScreenshot); s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; - document.head.appendChild(s); + uiAppendStyle(s); }); return msLoadPromise; } @@ -5235,11 +6169,113 @@ return '#ffffff'; } + function captureChromeNodes() { + const nodes = []; + const add = (node) => { + if (!node || node === document.body || nodes.includes(node)) return; + nodes.push(node); + }; + add(document.getElementById(PREFIX + '-root')); + [ + PREFIX + '-highlight', + PREFIX + '-tooltip', + PREFIX + '-bar', + PREFIX + '-picker', + PREFIX + '-params-panel', + PREFIX + '-insert-line', + PREFIX + '-insert-placeholder', + PREFIX + '-insert-create-tooltip', + PREFIX + '-annot', + PREFIX + '-design-host', + PREFIX + '-toast', + PREFIX + '-shader', + ].forEach((id) => add(uiGetById(id))); + return nodes; + } + + async function hideCaptureChromeForShaderProxy(fn) { + const saved = captureChromeNodes().map((node) => ({ + node, + visibility: node.style.visibility, + priority: node.style.getPropertyPriority('visibility'), + })); + for (const { node } of saved) { + node.style.setProperty('visibility', 'hidden', 'important'); + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + try { + return await fn(); + } finally { + for (const { node, visibility, priority } of saved) { + node.style.setProperty('visibility', visibility, priority); + } + } + } + + function shouldUseAncestorCropShaderProxy(el) { + // TODO: Enable this proxy for React/Vue/etc. adapters once their live + // preview mounts are covered by the same shader regression checks. + const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase(); + if (adapter === 'svelte' || adapter === 'sveltekit') return true; + if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true; + const wrapper = el?.closest?.('[data-impeccable-variants]'); + return wrapper?.dataset?.impeccablePreview === 'svelte-component'; + } + + function paintsShaderProxySurface(node) { + const s = getComputedStyle(node); + return !isTransparentColor(s.backgroundColor) + || (s.backgroundImage && s.backgroundImage !== 'none') + || paintsBackdrop(node); + } + + function findShaderProxyCaptureRoot(el) { + const doc = el.ownerDocument || document; + const er = el.getBoundingClientRect(); + let node = el.parentElement; + while (node && node !== doc.documentElement) { + const nr = node.getBoundingClientRect(); + const containsElement = + nr.width > 0 && nr.height > 0 && + nr.left <= er.left + 0.5 && + nr.top <= er.top + 0.5 && + nr.right >= er.right - 0.5 && + nr.bottom >= er.bottom - 0.5; + if (containsElement && paintsShaderProxySurface(node)) return node; + node = node.parentElement; + } + return null; + } + // Capture the element (with current annotations baked in) and return // { blob, paper }: the PNG Blob, plus the representative backdrop tone for the // shader's halftone ground (so capture, upload, and shader all agree on what // sits behind the element). Shared between the Go flow (uploads the blob) and // the shader-resume path. + async function captureElementFromRenderedAncestor(ms, el, opts) { + const doc = el.ownerDocument || document; + const captureRoot = findShaderProxyCaptureRoot(el); + if (!captureRoot) throw new Error('No painted ancestor for Svelte shader proxy'); + const rootCanvas = await ms.domToCanvas(captureRoot, opts); + const S = opts.scale; + const er = el.getBoundingClientRect(); + const rr = captureRoot.getBoundingClientRect(); + const sx = (er.left - rr.left) * S; + const sy = (er.top - rr.top) * S; + const sw = er.width * S; + const sh = er.height * S; + if (sw <= 0 || sh <= 0) throw new Error('Selected element has no visible capture rect'); + const crop = doc.createElement('canvas'); + crop.width = Math.max(1, Math.round(sw)); + crop.height = Math.max(1, Math.round(sh)); + const cctx = crop.getContext('2d', { willReadFrequently: true }); + cctx.drawImage(rootCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height); + const paper = dominantRgb01(cctx, crop.width, crop.height) || averageRgb01(cctx, crop.width, crop.height); + const blob = await new Promise((res) => crop.toBlob(res, 'image/png')); + if (!blob) throw new Error('Ancestor crop failed to produce a PNG blob'); + return { blob, paper }; + } + async function captureElementToBlob(el, snapshot, rect) { try { if (document.fonts?.ready) await document.fonts.ready; } catch {} const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); @@ -5261,6 +6297,13 @@ scale: Math.min(window.devicePixelRatio || 1, 2), font: fontCssText ? { cssText: fontCssText } : undefined, }; + if (shouldUseAncestorCropShaderProxy(el)) { + try { + return await hideCaptureChromeForShaderProxy(() => captureElementFromRenderedAncestor(ms, el, opts)); + } catch (err) { + console.warn('[impeccable] Svelte ancestor crop capture failed, falling back to element capture:', err); + } + } const bg = resolveCanvasBackground(el); // Fast path: the element paints its own background, or an opaque ancestor // color was found. modern-screenshot bakes that color; paper matches it. @@ -5342,13 +6385,13 @@ sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); } - // --------------------------------------------------------------------------- + // // Shader overlay - renders the captured screenshot as a WebGL texture and // runs an editorial "ink-wash" fragment shader over it during generation. // A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku // and leaving a soft trail. Makes the wait feel like a letterpress scan // instead of a dead spinner. - // --------------------------------------------------------------------------- + // const SHADER_VS = `attribute vec2 a_position; attribute vec2 a_uv; @@ -5502,6 +6545,31 @@ void main() { return n ? [r / n / 255, g / n / 255, b / n / 255] : SHADER_PAPER_FALLBACK; } + // Pick the most common visible color cluster from a crop. A straight average + // gets pulled by text and icons; the dominant bucket usually represents the + // surface the shader should dissolve into. + function dominantRgb01(ctx, w, h) { + const data = ctx.getImageData(0, 0, w, h).data; + const stride = Math.max(1, Math.floor((w * h) / 6000)); + const buckets = new Map(); + for (let p = 0; p < w * h; p += stride) { + const i = p * 4; + if (data[i + 3] < 16) continue; + const key = (data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4); + const bucket = buckets.get(key) || { count: 0, r: 0, g: 0, b: 0 }; + bucket.count += 1; + bucket.r += data[i]; + bucket.g += data[i + 1]; + bucket.b += data[i + 2]; + buckets.set(key, bucket); + } + let best = null; + for (const bucket of buckets.values()) { + if (!best || bucket.count > best.count) best = bucket; + } + return best ? [best.r / best.count / 255, best.g / best.count / 255, best.b / best.count / 255] : null; + } + // Average the backdrop sampled just OUTSIDE an element's rect within a larger // canvas. The ground tone for the dissolve must be the real backdrop, not the // mean of the element's own crop - averaging the crop folds in the element's @@ -5572,7 +6640,7 @@ void main() { fallback.style.backgroundRepeat = 'no-repeat'; fallback.style.outline = '2px dashed ' + C.brand; fallback.style.outlineOffset = '-2px'; - document.body.appendChild(fallback); + uiAppend(fallback); shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl }; } @@ -5582,16 +6650,19 @@ void main() { const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); + const radius = getComputedStyle(el).borderRadius; canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); Object.assign(canvas.style, { position: 'fixed', top: rect.top + 'px', left: rect.left + 'px', width: rect.width + 'px', height: rect.height + 'px', + borderRadius: radius, + overflow: 'hidden', pointerEvents: 'none', zIndex: Z.bar - 1, }); - document.body.appendChild(canvas); + uiAppend(canvas); const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) || canvas.getContext('experimental-webgl'); @@ -5685,8 +6756,12 @@ void main() { frame(); } - function handleAccept() { + async function handleAccept() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } + if (pendingAcceptedSession || state === 'SAVING') return; + if (variantSelectionPromise) { + try { await variantSelectionPromise; } catch { /* failed selection falls back below */ } + } if (!currentSessionId || arrivedVariants === 0) return; const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId); if (domVisibleVariant > 0) visibleVariant = domVisibleVariant; @@ -5696,30 +6771,39 @@ void main() { variantId: String(visibleVariant), pageUrl: location.pathname, }; + const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } + // The accepted variant is already the only visible child of the wrapper + // (all other variants are display:none). HMR from the source rewrite will + // replace the wrapper imminently. Don't eagerly replaceChild here - React + // reconciliation races with our mutation and throws NotFoundError in Next + // 16 / Turbopack. Schedule a fallback that runs the manual swap only if + // HMR hasn't cleaned up by then (keeps static-server flows working). const acceptedSessionId = currentSessionId; const acceptedVariant = visibleVariant; + const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId + || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - pendingAcceptedSession = { - id: acceptedSessionId, - variant: String(acceptedVariant), - ...acceptedSnapshot, - finalizing: false, - }; state = 'SAVING'; updateBarContent('saving'); + pendingAcceptedSession = { + id: acceptedSessionId, + variant: String(acceptedVariant), + isSvelteComponent: acceptedIsSvelteComponent, + ...acceptedSnapshot, + finalizing: false, + }; + saveSession(); sendEvent(acceptPayload, { throwOnError: true }) - .then(() => { - markSessionHandled(); - }) + .then(() => {}) .catch(() => { - pendingAcceptedSession = null; + if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); } @@ -5733,19 +6817,21 @@ void main() { } if (pending.finalizing) return true; pending.finalizing = true; - + markSessionHandled(); + if (pending.isSvelteComponent) { + commitAcceptedSvelteComponentToDom(pending.id); + } state = 'CONFIRMED'; updateBarContent('confirmed'); + scheduleAcceptCleanup(pending); + return true; + } - // Give framework HMR a short chance to render the now-clean accepted - // source. If it misses the update, unwrap the accepted variant after the - // source-side completion event so the page is not left empty or stale. + function scheduleAcceptCleanup(accepted) { setTimeout(function() { - ensureAcceptedDomClean(pending); + if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted); cleanupAcceptedSession(); }, 1200); - - return true; } function snapshotAcceptedVariantDom(sessionId, variantId) { @@ -5833,6 +6919,7 @@ void main() { stopScrollLock(); clearScrollY(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5841,6 +6928,28 @@ void main() { state = 'PICKING'; } + function commitAcceptedVariantToDom(sessionId, variantId) { + const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapper) return false; + const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); + if (!accepted || !accepted.firstElementChild) return false; + const parent = wrapper.parentElement; + if (!parent) return false; + + const style = wrapper.querySelector('style[data-impeccable-css]'); + if (style && !document.querySelector('style[data-impeccable-accepted-css="' + sessionId + '"]')) { + const promotedStyle = style.cloneNode(true); + promotedStyle.setAttribute('data-impeccable-accepted-css', sessionId); + parent.insertBefore(promotedStyle, wrapper); + } + + const committed = accepted.cloneNode(true); + committed.removeAttribute('hidden'); + committed.style.display = 'contents'; + parent.replaceChild(committed, wrapper); + return true; + } + function handleDiscard() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!currentSessionId) return; @@ -5852,11 +6961,141 @@ void main() { .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000)); } - // --------------------------------------------------------------------------- + // // Session persistence via live-browser-session.js - // --------------------------------------------------------------------------- + // // Survives page reloads, browser close/reopen, HMR, and accidental refreshes. + function normalizeSessionPath(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed.replace(/\\/g, '/') : null; + } + + function resetSessionFileMeta() { + currentSourceFile = null; + currentPreviewFile = null; + currentPreviewMode = null; + recoveryWaitingForAnchor = false; + } + + function rememberSessionFileMeta(meta = {}) { + const file = normalizeSessionPath(meta.file); + const sourceFile = normalizeSessionPath(meta.sourceFile); + const previewFile = normalizeSessionPath(meta.previewFile); + const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null); + + if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) { + currentPreviewMode = 'svelte-component'; + currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile); + currentSourceFile = sourceFile || currentSourceFile; + return; + } + + if (sourceFile || file) currentSourceFile = sourceFile || file; + if (previewFile) currentPreviewFile = previewFile; + if (previewMode) currentPreviewMode = previewMode; + } + + function applySavedSessionMeta(saved) { + if (!saved) return; + rememberSessionFileMeta(saved); + if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.action) selectedAction = saved.action; + if (saved.count) selectedCount = saved.count; + if (saved.previewMode) currentPreviewMode = saved.previewMode; + if (saved.paramValues && typeof saved.paramValues === 'object') { + paramsCurrentValues = { ...saved.paramValues }; + } + } + + function normalizePagePath(value) { + if (!value || typeof value !== 'string') return null; + try { + return new URL(value, location.origin).pathname; + } catch { + return value.split(/[?#]/)[0] || null; + } + } + + function pageMatchesCurrent(value) { + const path = normalizePagePath(value); + return !path || path === location.pathname; + } + + function isTerminalSessionSummary(session) { + return /^(completed|discarded|discard_requested|accept_requested)$/.test(String(session?.phase || '')); + } + + function findActiveSessionSummary(saved, activeSessions) { + if (!saved?.id || !Array.isArray(activeSessions)) return null; + return activeSessions.find((session) => + session?.id === saved.id + && pageMatchesCurrent(session.pageUrl || saved.pageUrl) + && !isTerminalSessionSummary(session) + ) || null; + } + + function clampVariantIndex(value, count) { + const num = Number(value); + const max = Number(count); + if (!Number.isFinite(num) || num < 1) return 0; + if (Number.isFinite(max) && max > 0 && num > max) return 0; + return Math.floor(num); + } + + function restoreSessionWithoutWrapper(reason, activeSessions) { + const saved = loadSession(); + if (!saved?.id || isSessionHandled(saved.id)) return false; + const savedState = String(saved.state || '').toUpperCase(); + if (savedState !== 'GENERATING' && savedState !== 'CYCLING') return false; + + const serverSession = findActiveSessionSummary(saved, activeSessions); + if (Array.isArray(activeSessions) && activeSessions.length > 0 && !serverSession) { + return false; + } + + currentSessionId = saved.id; + applySavedSessionMeta(serverSession); + applySavedSessionMeta(saved); + + expectedVariants = Number(saved.expected || serverSession?.expectedVariants || selectedCount || 0); + arrivedVariants = Number(saved.arrived || serverSession?.arrivedVariants || 0); + if (arrivedVariants <= 0 && currentPreviewFile) arrivedVariants = Number(serverSession?.expectedVariants || saved.expected || selectedCount || 0); + if (expectedVariants <= 0) expectedVariants = Number(serverSession?.expectedVariants || arrivedVariants || selectedCount || 0); + visibleVariant = clampVariantIndex(saved.visible, arrivedVariants || expectedVariants) + || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) + || (arrivedVariants > 0 ? 1 : 0); + + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + if (variantObserver) variantObserver.disconnect(); + variantObserver = startVariantObserver(currentSessionId); + saveSession(); + queueCheckpoint(reason || 'browser_restore_without_wrapper'); + + const restoreFile = currentPreviewMode === 'svelte-component' + ? currentPreviewFile + : (currentSourceFile || currentPreviewFile); + if (restoreFile) { + injectVariantsFromSource(restoreFile, currentSessionId); + return true; + } + + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return true; + } + + function restoreFromActiveSessions(activeSessions, reason) { + const wrapper = document.querySelector('[data-impeccable-variants]'); + if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false; + if (svelteComponentSession?.sessionId === currentSessionId) return false; + return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); + } + function saveSession() { if (!currentSessionId) return; // NOTE: scrollY is stored under a separate key (writeScrollY). Storing @@ -5869,6 +7108,11 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, + pageUrl: location.pathname, + paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, }); } @@ -5898,31 +7142,33 @@ void main() { } function cleanup() { - // Hide the wrapper immediately so variants disappear. DON'T structurally - // mutate the DOM yet - HMR from the agent's source rewrite is on its way, - // and a manual replaceChild under React causes NotFoundError when the - // reconciler later tries to remove a wrapper we already removed. - // Schedule a 2s fallback that does the manual swap only if HMR hasn't - // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const cleanupSessionId = currentSessionId; - if (cleanupSessionId) { + if (svelteComponentSession?.sessionId === cleanupSessionId) { + teardownSvelteComponentSession(true); + } else if (cleanupSessionId) { + // Hide the wrapper immediately so variants disappear. DON'T structurally + // mutate the DOM yet - HMR from the agent's source rewrite is on its way, + // and a manual replaceChild under React causes NotFoundError when the + // reconciler later tries to remove a wrapper we already removed. + // Schedule a 2s fallback that does the manual swap only if HMR hasn't + // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); if (wrapper) wrapper.style.display = 'none'; - } - setTimeout(function() { - if (!cleanupSessionId) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!wrapper) return; - const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - wrapper.parentElement.replaceChild(content, wrapper); - return; + setTimeout(function() { + if (!cleanupSessionId) return; + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) return; + const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); + if (orig) { + const content = orig.firstElementChild; + if (content) { + lateWrapper.parentElement.replaceChild(content, lateWrapper); + return; + } } - } - wrapper.remove(); - }, 2000); + lateWrapper.remove(); + }, 2000); + } hideBar(); hideHighlight(); stopScrollTracking(); @@ -5931,6 +7177,7 @@ void main() { clearScrollY(); finalizeInsertSession(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5938,9 +7185,9 @@ void main() { state = 'PICKING'; } - // --------------------------------------------------------------------------- + // // Toast - // --------------------------------------------------------------------------- + // function showToast(message, duration) { if (toastEl) toastEl.remove(); @@ -5964,7 +7211,7 @@ void main() { }); toastEl.id = PREFIX + '-toast'; toastEl.textContent = message; - document.body.appendChild(toastEl); + uiAppend(toastEl); requestAnimationFrame(() => { toastEl.style.opacity = '1'; toastEl.style.transform = 'translateX(-50%) translateY(0)'; @@ -5978,22 +7225,70 @@ void main() { }, duration); } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. function resumeSession() { const wrapper = document.querySelector('[data-impeccable-variants]'); - if (!wrapper) { clearSession(); clearHandled(); return false; } + if (!wrapper) { + if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } const sessionId = wrapper.dataset.impeccableVariants; // Don't resume if this session was already accepted/discarded if (isSessionHandled(sessionId)) return false; + // Svelte component sessions can't be resumed by counting DOM children: the + // wrapper holds a single mount target, not [data-impeccable-variant] nodes, + // and a page reload unmounts every compiled variant. Counting children here + // would strand the bar in CYCLING at 0/0. If there's no live in-memory mount + // for this wrapper, it's an orphan (reload / failed mount): drop it and let + // the live-server's SSE re-inject the manifest if the session is still live. + if (wrapper.dataset.impeccablePreview === 'svelte-component' + && svelteComponentSession?.sessionId !== sessionId) { + wrapper.remove(); + if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } + + if (wrapper.dataset.impeccablePreview === 'svelte-component') { + if (!svelteComponentSession?.mountedVariant) { + return true; + } + currentSessionId = sessionId; + expectedVariants = Number(wrapper.dataset.impeccableVariantCount) + || Number(svelteComponentSession.manifest?.count) + || expectedVariants + || 1; + arrivedVariants = expectedVariants; + const saved = loadSession(); + applySavedSessionMeta(saved); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = svelteComponentSession.mountedVariant > 0 && svelteComponentSession.mountedVariant <= arrivedVariants + ? svelteComponentSession.mountedVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = resolveSvelteComponentAnchor() + || wrapper.parentElement; + state = 'CYCLING'; + hideShaderOverlay(); + showBar('cycling'); + startScrollTracking(); + refreshParamsPanel(); + saveSession(); + queueCheckpoint('browser_resumed_svelte_component'); + return true; + } + currentSessionId = sessionId; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -6002,6 +7297,7 @@ void main() { // Restore state from localStorage if available const saved = loadSession(); if (saved && saved.id === sessionId) { + applySavedSessionMeta(saved); visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0); if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; @@ -6072,9 +7368,9 @@ void main() { return true; } - // --------------------------------------------------------------------------- + // // Global bar (always visible at bottom) - // --------------------------------------------------------------------------- + // let globalBarEl = null; let globalBarBrandEl = null; @@ -6166,6 +7462,8 @@ void main() { let pageChatExpanded = false; let steerLocked = false; let steerRequestId = null; + let steerPendingMessage = ''; + let steerInputWasFocused = false; let pageChatDotsEl = null; let steerAwaitTimer = null; let voiceRecognition = null; @@ -6179,7 +7477,7 @@ void main() { const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; - const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect'; + const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect'; const GLOBAL_BAR_SECTION_GAP = 8; const GLOBAL_BAR_INNER_GAP = 2; const GLOBAL_BAR_INNER_PAD_LEFT = 2; @@ -6323,7 +7621,7 @@ void main() { const attempt = () => { steerFocusRecoverTimer = null; if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (pageHasHostTextSelection()) { steerFocusRecoverTimer = setTimeout(attempt, 120); return; @@ -6344,7 +7642,7 @@ void main() { steerFocusSuspended = true; steerFocusPauseUntil = performance.now() + STEER_PAGE_FOCUS_PAUSE_MS; pagePointerGesture = { x: e.clientX, y: e.clientY, dragged: false }; - if (pageChatInput && document.activeElement === pageChatInput) { + if (pageChatInput && activeElementDeep() === pageChatInput) { pageChatInput.blur(); } } @@ -6404,7 +7702,7 @@ void main() { pickActive, pageChatReady: !!pageChatInput, pageChatExpanded, - active: steerFocusTargetLabel(document.activeElement), + active: steerFocusTargetLabel(activeElementDeep()), shouldSteer: shouldFocusSteerChat(), ...(extra || {}), }); @@ -6423,26 +7721,26 @@ void main() { function focusConfigureInput(reason) { steerFocusLog('focusConfigureInput', { reason }); const inputId = configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input'; - const input = document.getElementById(inputId); + const input = uiGetById(inputId); if (!input) { steerFocusLog('focusConfigureInput missing', { reason }); return; } setTimeout(() => { - const before = document.activeElement; + const before = activeElementDeep(); input.focus(); steerFocusLog('focusConfigureInput result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== input, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== input, }); }, 60); } function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; - const focused = document.activeElement === pageChatInput; + const focused = activeElementDeep() === pageChatInput; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; const P = pageChatPalette(); pageChatEl.style.borderColor = steerLocked @@ -6476,15 +7774,15 @@ void main() { } syncPageChatVisual(); pageChatInput.style.pointerEvents = 'auto'; - const before = document.activeElement; + const before = activeElementDeep(); try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== pageChatInput, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== pageChatInput, }); } @@ -6515,6 +7813,37 @@ void main() { return wrap; } + function keepSteerPointerInside(e, opts = {}) { + e.stopPropagation(); + if (opts.preventDefault !== false) e.preventDefault(); + } + + function preparePageChatInputForTyping() { + if (!pageChatEl || !pageChatInput) return false; + pageChatExpanded = true; + pageChatEl.dataset.expanded = 'true'; + pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; + pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return true; + } + + function focusPageChatInput(reason) { + if (!preparePageChatInputForTyping() || steerLocked) return false; + try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } + const focused = activeElementDeep() === pageChatInput; + if (focused) steerInputWasFocused = true; + syncPageChatFocusRing(); + return focused; + } + function clearSteerAwaitTimer() { if (steerAwaitTimer) { clearTimeout(steerAwaitTimer); @@ -6528,6 +7857,7 @@ void main() { if (!steerLocked || steerRequestId !== id) return; unlockSteerChat({ error: 'Steer timed out waiting for the agent. Check that live-poll is running and replies with steer_done.', + restoreMessage: steerPendingMessage, }); }, STEER_AWAIT_TIMEOUT_MS); } @@ -6538,19 +7868,12 @@ void main() { steerLocked = true; pageChatEl.dataset.processing = 'true'; pageChatInput.disabled = true; - pageChatInput.value = ''; - pageChatInput.blur(); + preparePageChatInputForTyping(); if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = true; pageChatVoiceBtn.style.display = 'none'; } - pageChatExpanded = false; - pageChatEl.dataset.expanded = 'false'; - pageChatEl.style.width = PAGE_CHAT_PROCESSING_W; pageChatEl.style.cursor = 'default'; - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; pageChatInput.style.pointerEvents = 'none'; if (pageChatHint) { pageChatHint.style.display = 'none'; @@ -6568,17 +7891,26 @@ void main() { function unlockSteerChat(opts) { clearSteerAwaitTimer(); + const restoreMessage = typeof opts?.restoreMessage === 'string' ? opts.restoreMessage : ''; + const keepExpanded = Boolean(opts?.error && restoreMessage); steerLocked = false; + const completedId = steerRequestId; steerRequestId = null; if (!pageChatEl) return; pageChatEl.dataset.processing = 'false'; pageChatEl.removeAttribute('aria-busy'); pageChatEl.setAttribute('aria-label', 'Steer the page'); - pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W; + pageChatExpanded = keepExpanded; + pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false'; + pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W; pageChatEl.style.cursor = 'pointer'; if (pageChatInput) { pageChatInput.disabled = false; - pageChatInput.value = ''; + pageChatInput.value = keepExpanded ? restoreMessage : ''; + pageChatInput.style.width = keepExpanded ? '' : '0'; + pageChatInput.style.padding = keepExpanded ? '0 6px' : '0'; + pageChatInput.style.opacity = keepExpanded ? '1' : '0'; + pageChatInput.style.pointerEvents = 'auto'; } if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = false; @@ -6586,18 +7918,28 @@ void main() { } if (pageChatHint) { pageChatHint.textContent = 'Steer'; - pageChatHint.style.display = ''; - pageChatHint.style.visibility = ''; + pageChatHint.style.display = keepExpanded ? 'none' : ''; + pageChatHint.style.visibility = keepExpanded ? 'hidden' : ''; + pageChatHint.style.opacity = keepExpanded ? '0' : '1'; } if (pageChatDotsEl?.parentNode) { pageChatDotsEl.remove(); pageChatDotsEl = null; } + steerPendingMessage = keepExpanded ? restoreMessage : ''; + steerInputWasFocused = false; syncPageChatChrome(); syncPageChatFocusRing(); if (opts?.error) showToast(String(opts.error), 5000); else if (opts?.message) showToast(String(opts.message), 4000); - syncPageChatFocus('steer-unlock'); + if (completedId) { + sendSteerCheckpoint(completedId, opts?.error ? 'steer_error' : 'steer_done', { + message: opts?.message || opts?.error || '', + file: opts?.file || '', + }); + } + if (keepExpanded) focusPageChatInput('steer-error-restore'); + else syncPageChatFocus('steer-unlock'); } function steerSpeechRecognitionCtor() { @@ -6651,7 +7993,7 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = document.getElementById(PREFIX + '-configure-voice'); + const voiceBtn = uiGetById(PREFIX + '-configure-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -6784,7 +8126,7 @@ void main() { } function configureVoiceContext() { - const input = document.getElementById( + const input = uiGetById( configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input', ); return { @@ -6819,26 +8161,37 @@ void main() { if (!text || steerLocked) return; const id = id8(); steerRequestId = id; + steerPendingMessage = text; + if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); lockSteerChat(); scheduleSteerAwaitTimeout(id); + sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); sendEvent({ type: 'steer', id, message: text, pageUrl: location.href, }).then((res) => { - if (!res) unlockSteerChat({ error: 'Could not reach live server' }); + if (!res) { + sendSteerCheckpoint(id, 'steer_send_failed', { message: text }); + unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text }); + } }); } function maybeCompleteSteer(msg) { if (!steerRequestId || msg.id !== steerRequestId) return false; if (msg.type === 'steer_done') { - unlockSteerChat({ message: msg.message }); + unlockSteerChat({ message: msg.message, file: msg.file }); + if (msg.file && /\.svelte(?:$|\?)/.test(String(msg.file))) { + setTimeout(() => { + if (!steerLocked) showToast('Steer applied. Reload if the page has not refreshed yet.', 5000); + }, 4500); + } return true; } if (msg.type === 'error') { - unlockSteerChat({ error: msg.message || 'Steer failed' }); + unlockSteerChat({ error: msg.message || 'Steer failed', restoreMessage: steerPendingMessage }); return true; } return false; @@ -6847,21 +8200,10 @@ void main() { function expandPageChat(opts) { const focus = !opts || opts.focus !== false; if (!pageChatEl || !pageChatInput || steerLocked) return; - pageChatExpanded = true; - pageChatEl.dataset.expanded = 'true'; - pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; - pageChatEl.style.cursor = 'text'; - if (pageChatHint) { - pageChatHint.style.display = 'none'; - pageChatHint.style.opacity = '0'; - } - pageChatInput.style.width = ''; - pageChatInput.style.padding = '0 6px'; - pageChatInput.style.opacity = '1'; - pageChatInput.style.pointerEvents = 'auto'; + preparePageChatInputForTyping(); syncPageChatChrome(); syncPageChatFocusRing(); - if (focus) pageChatInput.focus(); + if (focus) focusPageChatInput('expand-page-chat'); } function collapsePageChat(opts) { @@ -6878,7 +8220,7 @@ void main() { } else { pageChatInput.style.pointerEvents = 'auto'; } - if (pageChatHint && document.activeElement !== pageChatInput) { + if (pageChatHint && activeElementDeep() !== pageChatInput) { pageChatHint.style.display = ''; pageChatHint.style.opacity = '1'; } @@ -6952,7 +8294,7 @@ void main() { pageChatEl.appendChild(pageChatInput); pageChatEl.appendChild(pageChatVoiceBtn); - if (!document.getElementById(PREFIX + '-page-chat-style')) { + if (!uiGetById(PREFIX + '-page-chat-style')) { const s = document.createElement('style'); s.id = PREFIX + '-page-chat-style'; s.textContent = @@ -6966,23 +8308,34 @@ void main() { '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } - pageChatEl.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { + keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat(); + expandPageChat({ focus: false }); + focusPageChatInput('page-chat-click'); }); - pageChatVoiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); + pageChatVoiceBtn.addEventListener('mousedown', keepSteerPointerInside); pageChatVoiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); + keepSteerPointerInside(e); if (steerLocked) return; toggleSteerVoice(); }); + pageChatInput.addEventListener('pointerdown', keepSteerPointerInside); + pageChatInput.addEventListener('mousedown', keepSteerPointerInside); + pageChatInput.addEventListener('click', (e) => { + keepSteerPointerInside(e); + if (!steerLocked) focusPageChatInput('page-chat-input-click'); + }); + pageChatInput.addEventListener('input', () => { syncPageChatVisual(); }); @@ -6995,7 +8348,7 @@ void main() { syncPageChatFocusRing(); setTimeout(() => { if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (!pageChatInput.value.trim()) collapsePageChat(); scheduleSteerFocusRecover('steer-blur-recover'); }, 120); @@ -7039,7 +8392,7 @@ void main() { globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false'; globalBarBrandEl.setAttribute('aria-label', connected ? 'Impeccable live mode' - : 'Impeccable live mode: agent not polling'); + : 'Impeccable live mode - agent not polling'); globalBarBrandEl.removeAttribute('title'); globalBarBrandEl.style.cursor = connected ? 'default' : 'help'; const mark = globalBarBrandEl.querySelector('[data-brand-mark]'); @@ -7077,7 +8430,7 @@ void main() { }); agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip'; agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP; - document.body.appendChild(agentPollTooltipEl); + uiAppend(agentPollTooltipEl); return agentPollTooltipEl; } @@ -7131,7 +8484,7 @@ void main() { // Custom focus-visible for bar buttons. Browser default is a heavy // blue ring that looks jarring on the dark capsule. Replace with a // soft accent-tinted inner ring that respects the bar's palette. - if (!document.getElementById(PREFIX + '-bar-focus-style')) { + if (!uiGetById(PREFIX + '-bar-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-bar-focus-style'; s.textContent = @@ -7143,7 +8496,7 @@ void main() { '@keyframes impeccable-agent-dot { 0%, 100% { opacity: 0.45; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1); } }' + '#' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: impeccable-agent-dot 1.4s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: none; opacity: 0.9; } }'; - document.head.appendChild(s); + uiAppendStyle(s); } globalBarEl = el('div', { @@ -7176,7 +8529,7 @@ void main() { brand.id = PREFIX + '-global-bar-brand'; brand.dataset.agentConnected = 'false'; brand.setAttribute('role', 'img'); - brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling'); + brand.setAttribute('aria-label', 'Impeccable live mode - agent not polling'); const brandMark = el('span', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -7211,7 +8564,7 @@ void main() { inner.id = PREFIX + '-global-bar-inner'; globalBarEl.appendChild(inner); - // --- button factory: icon-only at rest, label slides in on hover/active --- + // Button factory: icon-only at rest, label slides in on hover/active. function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { const b = el('button', { position: 'relative', @@ -7506,6 +8859,7 @@ void main() { color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', }); + exitBtn.id = PREFIX + '-exit'; exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = 'oklch(58% 0.15 35)'; exitBtn.style.background = P.exitHover; }); @@ -7530,8 +8884,8 @@ void main() { try { window.focus(); } catch { /* in-app preview may block */ } }, true); - document.body.appendChild(pendingDockEl); - document.body.appendChild(globalBarEl); + uiAppend(pendingDockEl); + uiAppend(globalBarEl); defangOutsideHandlers(pendingDockEl); defangOutsideHandlers(globalBarEl); @@ -7553,11 +8907,11 @@ void main() { } function updateGlobalBarState() { - const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); - const detectBadge = document.getElementById(PREFIX + '-detect-badge'); - const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); - const insertToggle = document.getElementById(PREFIX + '-insert-toggle'); - const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const detectToggle = uiGetById(PREFIX + '-detect-toggle'); + const detectBadge = uiGetById(PREFIX + '-detect-badge'); + const pickToggle = uiGetById(PREFIX + '-pick-toggle'); + const insertToggle = uiGetById(PREFIX + '-insert-toggle'); + const designToggle = uiGetById(PREFIX + '-design-toggle'); const theme = globalBarEl?.dataset.theme || 'light'; const P = barPaletteForTheme(theme); @@ -7751,8 +9105,9 @@ void main() { pendingApplyInFlight = false; } if (globalBarEl) { - globalBarEl.style.transform = 'translateY(100%)'; - setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300); + globalBarEl.style.transition = 'none'; + globalBarEl.remove(); + globalBarEl = null; } pageChatEl = null; pageChatInput = null; @@ -7765,6 +9120,7 @@ void main() { if (barEl) { barEl.remove(); barEl = null; } if (pickerEl) { pickerEl.remove(); pickerEl = null; } if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; } + if (editBadgeProxyRoot) { editBadgeProxyRoot.remove(); editBadgeProxyRoot = null; editBadgeProxyByTarget = new Map(); } if (evtSource) { evtSource.close(); evtSource = null; } document.removeEventListener('mousemove', handleMouseMove, true); document.removeEventListener('click', handleClick, true); @@ -7777,9 +9133,9 @@ void main() { console.log('[impeccable] Live mode exited.'); } - // --------------------------------------------------------------------------- + // // Design System Panel - visualizes the project's .impeccable/design.json sidecar - // --------------------------------------------------------------------------- + // const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; const DESIGN_PANEL_WIDTH = 440; @@ -7847,7 +9203,7 @@ void main() { root.className = 'root'; designShadow.appendChild(root); - document.body.appendChild(designHost); + uiAppend(designHost); // The host is pointer-events: none; the panel inside the shadow DOM // manages its own auto/none. Events bubble through the shadow boundary, // so attaching here silences host-page outside-interaction handlers @@ -7889,7 +9245,7 @@ void main() { .root * { box-sizing: border-box; } button { font: inherit; color: inherit; } - /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + /* Panel shell: chrome matches the bar; body canvas stays neutral */ .panel { position: fixed; top: 12px; bottom: 72px; right: 12px; width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); @@ -7955,7 +9311,7 @@ void main() { .panel-body::-webkit-scrollbar { width: 8px; } .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } - /* --- States --- */ + /* States */ .empty, .loading, .error { margin: 16px 4px; padding: 28px 20px; text-align: center; @@ -7966,7 +9322,7 @@ void main() { .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; } .error { color: oklch(45% 0.15 25); } - /* --- Stale hint --- */ + /* Stale hint */ .stale { display: flex; align-items: center; gap: 8px; margin: 8px 4px 12px; @@ -7979,7 +9335,7 @@ void main() { .stale-text { flex: 1; min-width: 0; } .stale-text strong { color: ${DP.ink}; font-weight: 600; } - /* --- Parsed-md fallback banner --- */ + /* Parsed-md fallback banner */ .parsed-md-cta { margin: 8px 4px 14px; padding: 14px 16px; @@ -7991,7 +9347,7 @@ void main() { .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; } .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; } - /* --- Tile primitives --- */ + /* Tile primitives */ .tile { position: relative; background: ${DP.tile}; @@ -8010,7 +9366,7 @@ void main() { } .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; } - /* --- Color tile --- */ + /* Color tile */ .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; } .c-tile:hover { transform: translateY(-1px); } .c-hero { @@ -8025,7 +9381,7 @@ void main() { .c-ramp > span { flex: 1; } .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Type tile --- */ + /* Type tile */ .t-tile { } .t-specimen { margin: 4px 0 6px; @@ -8035,7 +9391,7 @@ void main() { .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; } .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Shadow tile --- */ + /* Shadow tile */ .s-tile { } .s-surface { height: 60px; margin: 8px 2px 10px; @@ -8045,14 +9401,14 @@ void main() { .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; } .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; } - /* --- Radii strip --- */ + /* Radii strip */ .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; } .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; } .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); } .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; } .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; } - /* --- Component tile (hosts live primitives) --- */ + /* Component tile (hosts live primitives) */ .cmp-tile { } .cmp-stage { margin: 12px -4px 0; @@ -8066,7 +9422,7 @@ void main() { .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; } .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; } - /* --- Collapsible --- */ + /* Collapsible */ .coll { margin: 0 4px 8px; background: ${DP.tile}; @@ -8131,7 +9487,7 @@ void main() { .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; } .coll .overview-body li { margin-bottom: 3px; } - /* --- raw tab markdown (unchanged layout, neutralized palette) --- */ + /* raw tab markdown (unchanged layout, neutralized palette) */ .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; } .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; } .md h1 { font-size: 18px; } @@ -8303,7 +9659,7 @@ void main() { return box; } - // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 --- + // Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 function renderDesignVisual(body, parsed, sidecar) { const frontmatter = parsed?.frontmatter || {}; @@ -8667,7 +10023,7 @@ void main() { return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components'); } - // --- Collapsibles --------------------------------------------------------- + // Collapsibles. function buildCollapsible(key, label, count) { const wrap = document.createElement('div'); @@ -8775,7 +10131,7 @@ void main() { return s.replace(/\s+#.*$/, '').trim(); } - // --- Raw tab: minimal markdown renderer (subset) -------------------------- + // Raw tab: minimal markdown renderer (subset) function renderRawTab(body, md) { const wrap = document.createElement('div'); @@ -8908,9 +10264,9 @@ void main() { } catch { /* ignore */ } } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // function init() { try { history.scrollRestoration = 'manual'; } catch {} diff --git a/plugin/skills/impeccable/scripts/live-completion.mjs b/plugin/skills/impeccable/scripts/live-completion.mjs index 86b637fff..986773066 100644 --- a/plugin/skills/impeccable/scripts/live-completion.mjs +++ b/plugin/skills/impeccable/scripts/live-completion.mjs @@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.mode === 'error') return 'error'; + if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error'; return 'agent_done'; } diff --git a/plugin/skills/impeccable/scripts/live-inject.mjs b/plugin/skills/impeccable/scripts/live-inject.mjs index b9d3df41d..3a1f36e46 100644 --- a/plugin/skills/impeccable/scripts/live-inject.mjs +++ b/plugin/skills/impeccable/scripts/live-inject.mjs @@ -17,11 +17,38 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './impeccable-paths.mjs'; +import { + applySvelteKitLiveAdapter, + detectSvelteKitProject, + removeSvelteKitLiveAdapter, +} from './live-sveltekit-adapter.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start'; +const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end'; + +export const LIVE_IGNORE_PATTERNS = Object.freeze([ + '.impeccable/hook.cache.json', + '.impeccable/live/server.json', + '.impeccable/live/sessions/', + '.impeccable/live/previews/', + '.impeccable/live/annotations/', + '.impeccable/live/cache/', + '.impeccable/live/manual-edit-apply-transaction.json', + '.impeccable/live/manual-edit-events.jsonl', + '.impeccable/live/manual-edit-evidence/', + '.impeccable/live/pending-manual-edits.json', + '.impeccable/live/deferred-svelte-component-accepts.json', + '.impeccable-live.json', + '.impeccable-live/', + 'node_modules/.impeccable-live/', + 'src/lib/impeccable/ImpeccableLiveRoot.svelte', + 'src/lib/impeccable/__runtime.js', + 'src/lib/impeccable/[0-9a-f]*/', +]); /** * Hard-excluded directory patterns. These are NEVER user-facing pages and @@ -83,8 +110,14 @@ Output (JSON): validateConfig(config); const resolvedFiles = resolveFiles(process.cwd(), config); + const svelteKit = detectSvelteKitProject(process.cwd(), config); if (args.includes('--remove')) { + if (svelteKit) { + const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config }); + console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; @@ -110,6 +143,13 @@ Output (JSON): console.error(JSON.stringify({ ok: false, error: 'missing_port' })); process.exit(1); } + const gitIgnore = ensureLiveGitIgnores(process.cwd()); + + if (svelteKit) { + const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config }); + console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); @@ -129,10 +169,68 @@ Output (JSON): }; }); const anyInserted = results.some((r) => r.inserted); - console.log(JSON.stringify({ ok: anyInserted, port, results })); + console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results })); if (!anyInserted) process.exit(1); } +export function ensureLiveGitIgnores(cwd = process.cwd()) { + const target = resolveIgnoreTarget(cwd); + const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : ''; + const block = [ + IGNORE_MARKER_OPEN, + ...LIVE_IGNORE_PATTERNS, + IGNORE_MARKER_CLOSE, + ].join('\n'); + const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`); + + let updated; + if (markerRe.test(existing)) { + updated = existing.replace(markerRe, block); + } else { + const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n'; + updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`; + } + + if (updated !== existing) { + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + fs.writeFileSync(target.path, updated, 'utf-8'); + } + + return { + file: path.relative(cwd, target.path).split(path.sep).join('/'), + mode: target.mode, + changed: updated !== existing, + patterns: [...LIVE_IGNORE_PATTERNS], + }; +} + +function resolveIgnoreTarget(cwd) { + const gitExcludePath = resolveGitInfoExcludePath(cwd); + if (gitExcludePath) { + return { path: gitExcludePath, mode: 'git-info-exclude' }; + } + return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' }; +} + +function resolveGitInfoExcludePath(cwd) { + const dotGit = path.join(cwd, '.git'); + if (!fs.existsSync(dotGit)) return null; + + const stat = fs.statSync(dotGit); + if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude'); + if (!stat.isFile()) return null; + + const body = fs.readFileSync(dotGit, 'utf-8').trim(); + const match = body.match(/^gitdir:\s*(.+)$/i); + if (!match) return null; + const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]); + return path.join(gitDir, 'info', 'exclude'); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Expand config.files (which may contain glob patterns) into a literal list * of existing file paths relative to rootDir. Literal entries pass through; diff --git a/plugin/skills/impeccable/scripts/live-insert.mjs b/plugin/skills/impeccable/scripts/live-insert.mjs index 09d4d55be..0658e9914 100644 --- a/plugin/skills/impeccable/scripts/live-insert.mjs +++ b/plugin/skills/impeccable/scripts/live-insert.mjs @@ -21,6 +21,11 @@ import { buildCssAuthoring, buildCssSelectorPrefixExamples, } from './live-wrap.mjs'; +import { + buildSvelteComponentCssAuthoring, + scaffoldSvelteComponentInsertSession, + shouldUseSvelteComponentInjection, +} from './live-svelte-component.mjs'; const INSERT_POSITIONS = new Set(['before', 'after']); @@ -192,6 +197,41 @@ Output (JSON): const styleMode = detectStyleMode(targetFile); const isJsx = commentSyntax.open === '{/*'; const spliceIndex = computeInsertLine(startLine, endLine, position); + const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); + + if (shouldUseSvelteComponentInjection(targetFile)) { + const session = scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile: relTargetFile, + insertLine: spliceIndex + 1, + position, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + anchorLines: lines.slice(startLine, endLine + 1), + cwd: process.cwd(), + }); + console.log(JSON.stringify({ + mode: 'insert', + position, + file: session.manifestFile, + sourceFile: relTargetFile, + previewMode: 'svelte-component', + componentDir: session.componentDir, + propContract: session.propContract, + insertLine: 1, + sourceInsertLine: spliceIndex + 1, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + commentSyntax, + styleMode: 'svelte-component', + styleTag: null, + cssSelectorPrefixExamples: [], + cssAuthoring: buildSvelteComponentCssAuthoring(count), + })); + return; + } + const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? ''; @@ -216,7 +256,7 @@ Output (JSON): console.log(JSON.stringify({ mode: 'insert', position, - file: path.relative(process.cwd(), targetFile), + file: relTargetFile, insertLine: insertLine + 1, commentSyntax, styleMode: styleMode.mode, diff --git a/plugin/skills/impeccable/scripts/live-poll.mjs b/plugin/skills/impeccable/scripts/live-poll.mjs index fad836612..1e1259fbc 100644 --- a/plugin/skills/impeccable/scripts/live-poll.mjs +++ b/plugin/skills/impeccable/scripts/live-poll.mjs @@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs'; // that ceiling and loop in `pollOnce` to synthesize a long poll without // depending on the standalone undici package. export const PER_REQUEST_TIMEOUT_MS = 270_000; +export const DEFAULT_EVENT_LEASE_MS = 600_000; const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); @@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) { ? totalDeadline - Date.now() : PER_REQUEST_TIMEOUT_MS; const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); - const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); + const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`); if (res.status === 401) { const err = new Error('Authentication failed. The server token may have changed.'); @@ -317,7 +318,7 @@ Modes: Options: --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) - --file PATH Attach a source file path to the reply (generate flow) + --file PATH Attach a source file path to the reply (generate/steer flow) --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --help Show this help message diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs index 16c8285b9..cd1091b88 100644 --- a/plugin/skills/impeccable/scripts/live-server.mjs +++ b/plugin/skills/impeccable/scripts/live-server.mjs @@ -42,6 +42,10 @@ import { } from './live-manual-edits-buffer.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs'; +import { + applyDeferredSvelteComponentAccepts, + removeAllSvelteComponentSessions, +} from './live-svelte-component.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated @@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; +const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2; const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); function tombstoneTimedOutApplyId(eventId, details = {}) { @@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) { return entry.event; } entry.leaseUntil = Date.now() + leaseMs; + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return entry.event; } @@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) { const acknowledged = state.pendingEvents[idx].event; state.pendingEvents.splice(idx, 1); scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return acknowledged; } +function findPendingEventById(id) { + if (!id) return null; + const entry = state.pendingEvents.find((item) => item.event?.id === id); + return entry?.event || null; +} + function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; return `live-poll.mjs --reply ${id} done --data ''`; @@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) { return summary; } +function summarizeActiveSessionForClient(snapshot = {}) { + return { + id: snapshot.id, + phase: snapshot.phase, + pageUrl: snapshot.pageUrl ?? null, + sourceFile: snapshot.sourceFile ?? null, + previewFile: snapshot.previewFile ?? null, + previewMode: snapshot.previewMode ?? null, + expectedVariants: snapshot.expectedVariants ?? 0, + arrivedVariants: snapshot.arrivedVariants ?? 0, + visibleVariant: snapshot.visibleVariant ?? null, + checkpointRevision: snapshot.checkpointRevision ?? 0, + paramValues: snapshot.paramValues || {}, + }; +} + +function activeSessionSummaries() { + if (!state.sessionStore) return []; + return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot)); +} + +function cancelQueuedAnonymousExitEvents() { + let removed = 0; + for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) { + const event = state.pendingEvents[i]?.event; + if (event?.type !== 'exit' || event.id) continue; + state.pendingEvents.splice(i, 1); + removed += 1; + } + if (removed > 0) { + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); + } + return removed; +} + function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { const canceledById = new Map(); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); @@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() { clearTimeout(state.leaseTimer); state.leaseTimer = null; } - if (state.pendingPolls.length === 0) return; const now = Date.now(); const nextLeaseUntil = state.pendingEvents .map((entry) => entry.leaseUntil || 0) @@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() { state.leaseTimer = setTimeout(() => { state.leaseTimer = null; flushPendingPolls(); - }, Math.max(0, nextLeaseUntil - now)); + broadcastAgentPollingIfChanged(); + }, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS)); } function flushPendingPolls() { @@ -1032,7 +1082,9 @@ function flushPendingPolls() { } function agentPollingConnected() { - return state.pendingPolls.length > 0; + const now = Date.now(); + return state.pendingPolls.length > 0 + || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now); } function broadcastAgentPollingIfChanged() { @@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/status') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } - const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; + const sessions = activeSessionSummaries(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', @@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/events' && req.method === 'GET') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + clearTimeout(state.exitTimer); + state.exitTimer = null; + cancelQueuedAnonymousExitEvents(); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { type: 'connected', hasProjectContext: hasProjectContext(), agentPolling: agentPollingConnected(), + activeSessions: activeSessionSummaries(), }) + '\n\n'); state.sseClients.add(res); - clearTimeout(state.exitTimer); // Keepalive: SSE comment every 30s prevents silent connection drops. const heartbeat = setInterval(() => { @@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } } + if (msg.type === 'exit') { + cleanupSvelteComponentSessionsBeforeExit(); + } if (msg.type !== 'checkpoint') { enqueueEvent(msg); } @@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) { }); } +function sessionFileMetadataFromPollReply(file) { + if (!file || typeof file !== 'string') return { file }; + const normalized = file.split(path.sep).join('/'); + const base = { file: normalized }; + if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base; + if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base; + + let full; + try { + full = path.resolve(process.cwd(), normalized); + const rel = path.relative(process.cwd(), full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base; + } catch { + return base; + } + + try { + const manifest = JSON.parse(fs.readFileSync(full, 'utf-8')); + if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base; + return { + file: String(manifest.sourceFile).split(path.sep).join('/'), + sourceFile: String(manifest.sourceFile).split(path.sep).join('/'), + previewFile: normalized, + previewMode: 'svelte-component', + }; + } catch { + return base; + } +} + function handlePollPost(req, res) { let body = ''; req.on('data', (c) => { body += c; }); @@ -1965,6 +2053,16 @@ function handlePollPost(req, res) { res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); return; } + const pendingEventBeforeAck = findPendingEventById(msg.id); + if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done' + && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'steer_done_requires_file_or_message', + hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.', + })); + return; + } const acknowledgedEvent = acknowledgePendingEvent(msg.id); let skipJournalReply = false; let existingSession = null; @@ -1987,6 +2085,7 @@ function handlePollPost(req, res) { })); return; } + const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); if (state.sessionStore && msg.id && !skipJournalReply) { try { const eventType = msg.type === 'steer_done' @@ -2001,7 +2100,10 @@ function handlePollPost(req, res) { state.sessionStore.appendEvent({ type: eventType, id: msg.id, - file: msg.file, + file: replyFileMeta.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, message: msg.message, sourceEventType: acknowledgedEvent?.type, carbonize: msg.data?.carbonize === true, @@ -2010,7 +2112,16 @@ function handlePollPost(req, res) { } flushPendingPolls(); // Forward the reply to the browser via SSE - broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + broadcast({ + type: msg.type || 'done', + id: msg.id, + message: msg.message, + file: msg.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, + data: msg.data, + }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); @@ -2023,6 +2134,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { + cleanupSvelteComponentSessionsBeforeExit(); removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; @@ -2037,6 +2149,25 @@ function shutdown() { process.exit(0); } +function cleanupSvelteComponentSessionsBeforeExit() { + try { + removeAllSvelteComponentSessions(process.cwd()); + } catch (err) { + console.warn('[impeccable] Svelte component session cleanup failed:', err.message); + } +} + +function applyLegacyDeferredAcceptsOnStartup() { + try { + const result = applyDeferredSvelteComponentAccepts(process.cwd()); + if (result.applied > 0 || result.failed > 0) { + console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result)); + } + } catch (err) { + console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({ cwd: process.cwd(), reason: 'manual_edit_server_start_recovered_abandoned_transaction', }); +applyLegacyDeferredAcceptsOnStartup(); restorePendingEventsFromStore(); pruneStaleManualApplyEvidence(process.cwd()); const portArg = args.find(a => a.startsWith('--port=')); diff --git a/plugin/skills/impeccable/scripts/live-session-store.mjs b/plugin/skills/impeccable/scripts/live-session-store.mjs index 7562e3d5b..5ec4d34d4 100644 --- a/plugin/skills/impeccable/scripts/live-session-store.mjs +++ b/plugin/skills/impeccable/scripts/live-session-store.mjs @@ -106,6 +106,8 @@ function baseSnapshot(id) { phase: 'new', pageUrl: null, sourceFile: null, + previewFile: null, + previewMode: null, expectedVariants: 0, arrivedVariants: 0, visibleVariant: null, @@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; - next.sourceFile = event.file ?? next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0); next.pendingEventSeq = null; next.pendingEvent = null; if (event.carbonize === true) { @@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { } break; case 'checkpoint': + if (COMPLETED_PHASES.has(next.phase)) { + next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null }); + break; + } if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { next.phase = event.phase ?? next.phase; next.checkpointRevision = event.revision ?? next.checkpointRevision; next.activeOwner = event.owner ?? next.activeOwner; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.visibleVariant = event.visibleVariant ?? next.visibleVariant; + next.sourceFile = event.sourceFile ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; if (event.paramValues) next.paramValues = { ...event.paramValues }; } else { next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); @@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'steer_done': next.phase = 'steer_done'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.message = event.message ?? next.message; next.pendingEventSeq = null; next.pendingEvent = null; break; @@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'complete': next.phase = 'completed'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; next.pendingEventSeq = null; next.pendingEvent = null; break; diff --git a/plugin/skills/impeccable/scripts/live-svelte-component.mjs b/plugin/skills/impeccable/scripts/live-svelte-component.mjs new file mode 100644 index 000000000..dc35dc0ce --- /dev/null +++ b/plugin/skills/impeccable/scripts/live-svelte-component.mjs @@ -0,0 +1,826 @@ +/** + * Svelte live-mode component injection helpers. + * + * Variants are real .svelte components under node_modules/.impeccable-live//. + * The browser mounts them via Svelte 5 mount(); accept inlines the chosen + * variant back into the route source with props mapped to original bindings. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; + +export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live'; +export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`; +export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json'; + +const MUSTACHE_RE = /\{([^{}]+)\}/g; + +export function shouldUseSvelteComponentInjection(filePath) { + if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false; + return path.extname(filePath).toLowerCase() === '.svelte'; +} + +export function componentSessionDir(id, cwd = process.cwd()) { + return path.join(cwd, SVELTE_COMPONENT_ROOT, id); +} + +export function manifestPathForSession(id, cwd = process.cwd()) { + return path.join(componentSessionDir(id, cwd), 'manifest.json'); +} + +export function ensureRuntimeHelper(cwd = process.cwd()) { + const file = path.join(cwd, SVELTE_RUNTIME_FILE); + if (fs.existsSync(file)) return file; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8'); + return file; +} + +/** + * Extract ordered unique mustache expressions from markup (not inside ). + */ +export function extractMustacheExpressions(text) { + const expressions = []; + const seen = new Set(); + const lines = String(text || '').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('\n` + : ''; + return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n\n`; +} + +function buildInsertVariantStub(variantNum) { + return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; +} + +export function scaffoldSvelteComponentSession({ + id, + count, + sourceFile, + sourceStartLine, + sourceEndLine, + originalLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const originalMarkup = originalLines.join('\n'); + const contract = buildPropContract(extractMustacheExpressions(originalMarkup)); + const originalWithProps = substituteExprsWithProps(originalMarkup, contract); + + const manifest = { + id, + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + sourceStartLine, + sourceEndLine, + count, + propContract: contract, + originalMarkup, + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: contract, + }; +} + +export function scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile, + insertLine, + position, + anchorStartLine, + anchorEndLine, + anchorLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const anchorMarkup = (anchorLines || []).join('\n'); + const manifest = { + id, + mode: 'insert', + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + insertLine, + position, + anchorStartLine, + anchorEndLine, + originalMarkup: anchorMarkup, + anchorMarkup, + count, + propContract: [], + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: [], + }; +} + +export function findSvelteComponentManifest(id, cwd = process.cwd()) { + const direct = manifestPathForSession(id, cwd); + if (fs.existsSync(direct)) { + return readManifest(direct); + } + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return null; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(root, entry.name, 'manifest.json'); + if (!fs.existsSync(candidate)) continue; + try { + const manifest = readManifest(candidate); + if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; + } catch { /* skip */ } + } + return null; +} + +export function readManifest(manifestPath) { + const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + return { + ...data, + manifestPath, + }; +} + +export function resolveSourceFile(sourceFile, cwd = process.cwd()) { + if (!sourceFile || path.isAbsolute(sourceFile)) { + throw new Error('Invalid svelte-component source file'); + } + const full = path.resolve(cwd, sourceFile); + const rel = path.relative(cwd, full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('Svelte-component source file escapes project root'); + } + if (!fs.existsSync(full)) { + throw new Error('Svelte-component source file not found: ' + sourceFile); + } + return full; +} + +function appendCssToSvelteStyle(lines, cssLines) { + const closeIdx = findLastStyleCloseLine(lines); + const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; + if (closeIdx === -1) { + return [...lines, '', '']; + } + return [ + ...lines.slice(0, closeIdx), + ...prepared, + ...lines.slice(closeIdx), + ]; +} + +function findLastStyleCloseLine(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + if (/<\/style\s*>/.test(lines[i])) return i; + } + return -1; +} + +function bakeParamValuesInCss(cssLines, paramValues) { + if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; + return cssLines.map((line) => { + let out = line; + for (const [key, value] of Object.entries(paramValues)) { + const varName = `--p-${key}`; + out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); + } + return out; + }); +} + +function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { + const css = String((cssLines || []).join('\n')); + if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; + + const rules = parseCssRules(css); + const output = []; + for (const rule of rules) { + appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); + } + return output.join('\n') + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ''); +} + +function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { + const prelude = rule.prelude.trim(); + const body = rule.body.trim(); + if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; + + if (/^@scope\b/i.test(prelude)) { + if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; + const inner = parseCssRules(body); + for (const innerRule of inner) { + const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); + if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; + output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); + } + return; + } + + const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); + if (!rewrittenPrelude) return; + output.push(formatCssRule(rewrittenPrelude, body)); +} + +function parseCssRules(css) { + const rules = []; + const text = String(css || ''); + let i = 0; + while (i < text.length) { + while (i < text.length && /\s/.test(text[i])) i++; + const preludeStart = i; + while (i < text.length && text[i] !== '{') i++; + if (i >= text.length) break; + const prelude = text.slice(preludeStart, i).trim(); + i++; + const bodyStart = i; + let depth = 1; + let quote = null; + let comment = false; + while (i < text.length && depth > 0) { + const ch = text[i]; + const next = text[i + 1]; + if (comment) { + if (ch === '*' && next === '/') { + comment = false; + i += 2; + continue; + } + i++; + continue; + } + if (quote) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === quote) quote = null; + i++; + continue; + } + if (ch === '/' && next === '*') { + comment = true; + i += 2; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); + if (prelude) rules.push({ prelude, body }); + } + return rules; +} + +function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { + const selectors = splitSelectorList(prelude); + const rewritten = []; + for (const selector of selectors) { + const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); + if (next) rewritten.push(next); + } + return rewritten.join(', '); +} + +function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { + let out = selector.trim(); + const hasVariant = /data-impeccable-variant/.test(out); + if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; + if (hasVariant) { + out = out.replace(variantSelectorRegex(variantNum), ''); + out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); + } + + const paramResult = rewriteParamSelectors(out, paramValues); + if (!paramResult.keep) return ''; + out = paramResult.selector; + + out = out + .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') + .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') + .replace(/\s+/g, ' ') + .trim(); + + out = out.replace(/^[>+~]\s*/, '').trim(); + if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; + return out; +} + +function rewriteParamSelectors(selector, paramValues) { + let keep = true; + const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { + if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; + const actual = paramValues[key]; + if (expected != null && String(actual) !== String(expected)) { + keep = false; + return ''; + } + if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { + keep = false; + return ''; + } + return ''; + }); + return { keep, selector: next }; +} + +function splitSelectorList(prelude) { + const selectors = []; + let start = 0; + let bracket = 0; + let paren = 0; + let quote = null; + for (let i = 0; i < prelude.length; i++) { + const ch = prelude[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '[') bracket++; + else if (ch === ']') bracket = Math.max(0, bracket - 1); + else if (ch === '(') paren++; + else if (ch === ')') paren = Math.max(0, paren - 1); + else if (ch === ',' && bracket === 0 && paren === 0) { + selectors.push(prelude.slice(start, i)); + start = i + 1; + } + } + selectors.push(prelude.slice(start)); + return selectors; +} + +function selectorHasVariant(selector, variantNum) { + return variantSelectorRegex(variantNum).test(selector); +} + +function variantSelectorRegex(variantNum) { + return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); +} + +function formatCssRule(selector, body) { + return `${selector} { ${body.trim()} }`; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { + const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); + const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); + const resultBase = { + file: manifest.sourceFile, + sourceFile: manifest.sourceFile, + previewMode: 'svelte-component', + componentDir: manifest.componentDir, + carbonize: false, + }; + if (!fs.existsSync(variantPath)) { + return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; + } + + const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); + if (manifest.mode === 'insert') { + return inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, + }); + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const contract = manifest.propContract || []; + const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); + const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract) + .split('\n') + .map((line) => line.trimEnd()); + + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const start = Number(manifest.sourceStartLine) - 1; + const end = Number(manifest.sourceEndLine) - 1; + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { + return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; + } + + const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, start), + ...indentedMarkup, + ...sourceLines.slice(end + 1), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, +}) { + if (!svelteMarkupHasVisibleContent(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; + } + if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const restoredMarkup = String(markup || '') + .split('\n') + .map((line) => line.trimEnd()); + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const insertIndex = Number(manifest.insertLine) - 1; + if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { + return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; + } + + const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; + const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, insertIndex), + ...indentedMarkup, + ...sourceLines.slice(insertIndex), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function svelteMarkupHasVisibleContent(markup) { + const text = String(markup || '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 0) return true; + return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); +} + +function mergeOriginalTopLevelAttrs(markup, originalMarkup) { + const variantOpen = matchOpeningTag(markup); + const originalOpen = matchOpeningTag(originalMarkup); + if (!variantOpen || !originalOpen) return markup; + if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; + + const variantAttrs = parseAttrSegments(variantOpen.attrs); + const originalAttrs = parseAttrSegments(originalOpen.attrs); + const additions = []; + let attrs = variantOpen.attrs; + + const originalClass = originalAttrs.get('class'); + const variantClass = variantAttrs.get('class'); + if (originalClass && variantClass) { + const merged = mergeStaticClassAttr(originalClass, variantClass); + if (merged) { + attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); + variantAttrs.set('class', { ...variantClass, raw: merged }); + } + } else if (originalClass && !variantClass) { + additions.push(originalClass.raw); + } + + for (const [name, attr] of originalAttrs) { + if (name === 'class') continue; + if (!variantAttrs.has(name)) additions.push(attr.raw); + } + + if (additions.length === 0 && attrs === variantOpen.attrs) return markup; + const nextOpen = variantOpen.prefix + + variantOpen.tag + + attrs + + additions.map((attr) => ' ' + attr.trim()).join('') + + variantOpen.close; + return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); +} + +function matchOpeningTag(markup) { + const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); + if (!match) return null; + return { + raw: match[0], + prefix: match[1], + tag: match[2], + attrs: match[3] || '', + close: match[4], + index: match.index || 0, + }; +} + +function parseAttrSegments(attrs) { + const out = new Map(); + const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; + let match; + while ((match = re.exec(attrs))) { + const raw = match[0]; + const name = match[1]; + out.set(name, { + name, + raw, + start: match.index, + end: match.index + raw.length, + }); + } + return out; +} + +function mergeStaticClassAttr(originalClass, variantClass) { + const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + if (!originalValue || !variantValue) return null; + const quote = variantValue[1]; + const classes = [ + ...variantValue[2].split(/\s+/), + ...originalValue[2].split(/\s+/), + ].filter(Boolean); + return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; +} + +export function removeSvelteComponentSession(id, cwd = process.cwd()) { + const dir = componentSessionDir(id, cwd); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* non-fatal */ } +} + +export function removeAllSvelteComponentSessions(cwd = process.cwd()) { + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('__')) continue; + try { + fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + } catch { /* non-fatal */ } + } +} + +export function deferredAcceptsPath(cwd = process.cwd()) { + const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16); + return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json'); +} + +export function readDeferredAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch { + return { accepts: [] }; + } +} + +export function writeDeferredAccept(entry, cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const data = readDeferredAccepts(cwd); + data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id); + data.accepts.push({ ...entry, createdAt: new Date().toISOString() }); + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + const data = readDeferredAccepts(cwd); + const pending = Array.isArray(data.accepts) ? data.accepts : []; + const results = []; + const remaining = []; + for (const entry of pending) { + try { + const manifest = findSvelteComponentManifest(entry.id, cwd); + if (!manifest) { + results.push({ id: entry.id, ok: false, error: 'manifest not found' }); + remaining.push(entry); + continue; + } + const result = inlineSvelteComponentAccept( + manifest, + entry.variantNum, + entry.paramValues || null, + cwd, + ); + results.push({ id: entry.id, ok: result.handled !== false, result }); + if (result.handled === false) remaining.push(entry); + } catch (err) { + results.push({ id: entry.id, ok: false, error: err.message }); + remaining.push(entry); + } + } + if (remaining.length > 0) { + fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8'); + } else { + try { fs.rmSync(file, { force: true }); } catch {} + } + return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results }; +} + +export function buildSvelteComponentCssAuthoring(count) { + const variantNumbers = Array.from({ length: count }, (_, i) => i + 1); + return { + mode: 'svelte-component', + styleTag: null, + strategy: 'component-style-block', + rulePattern: '.semantic-class { ... }', + selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'), + requirements: [ + 'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).', + 'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.', + 'Put variant CSS in the component ' : '')); + if (paramValues && Object.keys(paramValues).length > 0) { + lines.push( + bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close, + ); + } + lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + lines.push(bodyIndent + '
'); + lines.push(...bodyRestored); + lines.push(bodyIndent + '
'); + }; + + if (isJsx) { + const wrapperStyle = 'style={{ display: "contents" }}'; + lines.push(indent + '
'); + pushCarbonizeBody(indent + ' '); + lines.push(indent + '
'); + } else { + pushCarbonizeBody(indent); + } + + return lines; +} + +function reindentContent(contentLines, fromIndent, toIndent) { + return contentLines.map((line) => { + if (line.trim() === '') return ''; + if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length); + return toIndent + line.trimStart(); + }); +} + function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; @@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const hasHelperAttrs = variantText.includes('data-impeccable-variant'); const needsCarbonize = !!(cssContent || hasHelperAttrs); - // Build the replacement const restored = deindentContent(variantContent, indent); - const replacement = []; - - if (cssContent) { - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close); - // JSX targets need the CSS body wrapped in a template literal so that the - // `{` and `}` in CSS rules don't get parsed as JSX expressions. - replacement.push(indent + '' : '')); - if (paramValues && Object.keys(paramValues).length > 0) { - // Preserve the user's knob positions for the carbonize-cleanup agent - // to bake into the final CSS when it collapses scoped rules. - replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); - } - replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); - } - - // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the - // carbonize CSS block working visually by re-wrapping the accepted content - // in a data-impeccable-variant="N" div with `display: contents` (so layout - // isn't affected). The carbonize agent strips this attribute + wrapper when - // it moves the CSS to a proper stylesheet. - // - // Style attribute syntax has to follow the host file's flavor — JSX files - // need the object form, otherwise React 19 throws "Failed to set indexed - // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. - if (cssContent) { - const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; - replacement.push(indent + '
'); - replacement.push(...restored); - replacement.push(indent + '
'); - } else { - replacement.push(...restored); - } + const replacement = buildCarbonizeReplacement({ + indent, + commentSyntax, + isJsx, + id, + variantNum, + cssContent, + paramValues, + restored, + }); const newLines = [ ...lines.slice(0, replaceRange.start), @@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') }; } +function readSourceShadowPreviewMeta(content, id) { + const escaped = escapeRegExp(id); + const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>'); + const match = String(content || '').match(wrapperRe); + if (!match) return null; + const tag = match[0]; + if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null; + const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file'); + const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start')); + const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end')); + if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null; + return { sourceFile, sourceStartLine, sourceEndLine }; +} + +function readHtmlAttr(tag, name) { + const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1')); + if (!match) return null; + return decodeHtmlAttr(match[2]); +} + +function decodeHtmlAttr(value) { + return String(value || '') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- @@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs acceptCli(); } -export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock }; +export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts }; diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index f609ac847..d7c580e2f 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -26,9 +26,9 @@ return; } - // --------------------------------------------------------------------------- + // // Design tokens - // --------------------------------------------------------------------------- + // // Brand kinpaku (gold) is pinned to the site's neo-kinpaku tokens // (see site/styles/kinpaku-tokens.css) so Accept / knobs / cycle-dots / @@ -115,19 +115,48 @@ { value: 'overdrive', label: 'Overdrive' }, ]; - // --------------------------------------------------------------------------- + const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions']; + const LIVE_UI_SURFACES = [ + { 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'] }, + { key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] }, + { key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-configure-input-wrap', PREFIX + '-input', PREFIX + '-configure-voice'] }, + { key: 'action-picker', ids: [PREFIX + '-picker'] }, + { key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] }, + { key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] }, + { key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] }, + { key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] }, + { key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] }, + { 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'] }, + { key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] }, + { key: 'design-system-panel', ids: [PREFIX + '-design-host'] }, + { key: 'toasts-and-errors', ids: [PREFIX + '-toast'] }, + { key: 'css-isolation-boundary', ids: [PREFIX + '-root'] }, + ]; + const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))]; + + // // State - // --------------------------------------------------------------------------- + // let state = 'IDLE'; let hoveredElement = null; let selectedElement = null; let currentSessionId = null; - let pendingAcceptedSession = null; let expectedVariants = 0; let arrivedVariants = 0; let visibleVariant = 0; + let svelteComponentSession = null; + let svelteRuntimePromise = null; + let pendingSvelteComponentRetryObserver = null; + let currentSourceFile = null; + let currentPreviewFile = null; + let currentPreviewMode = null; + let recoveryWaitingForAnchor = false; + let pendingAcceptedSession = null; let variantObserver = null; + let variantSelectionInFlight = false; + let variantSelectionPromise = null; + let recoveringEmptyCycling = false; let hasProjectContext = false; let selectedAction = 'impeccable'; let selectedCount = 3; @@ -175,14 +204,17 @@ let highlightEl = null; let tooltipEl = null; let barEl = null; + let barHideSeq = 0; let pickerEl = null; let toastEl = null; let scrollRaf = null; let editBadgeEl = null; + let editBadgeProxyRoot = null; + let editBadgeProxyByTarget = new Map(); - // --------------------------------------------------------------------------- + // // Helpers - // --------------------------------------------------------------------------- + // function own(el) { return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); @@ -204,8 +236,105 @@ return s; } + function rectIsUsableAnchor(rect) { + return !!rect && rect.width > 0.5 && rect.height > 0.5; + } + + function makeFrozenAnchor(el) { + if (!el || !el.getBoundingClientRect) return null; + const r = el.getBoundingClientRect(); + if (!rectIsUsableAnchor(r)) return null; + const rect = { + x: r.x, y: r.y, + top: r.top, left: r.left, + right: r.right, bottom: r.bottom, + width: r.width, height: r.height, + }; + return { + __impeccableFrozenAnchor: true, + tagName: el.tagName || 'DIV', + id: el.id || '', + classList: el.classList ? [...el.classList] : [], + hasAttribute: () => false, + getBoundingClientRect: () => rect, + }; + } + function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } + function cssId(id) { + if (window.CSS?.escape) return CSS.escape(id); + return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); + } + + function liveUiRoot() { + const root = window.__IMPECCABLE_LIVE_UI_ROOT__; + if (root && typeof root.appendChild === 'function') return root; + return document.body; + } + + function uiAppend(el) { + liveUiRoot().appendChild(el); + return el; + } + + function uiAppendStyle(styleEl) { + const root = liveUiRoot(); + if (root && root !== document.body) root.appendChild(styleEl); + else document.head.appendChild(styleEl); + return styleEl; + } + + function uiGetById(id) { + const root = liveUiRoot(); + if (root?.getElementById) { + const found = root.getElementById(id); + if (found) return found; + } + if (root?.querySelector) { + const found = root.querySelector('#' + cssId(id)); + if (found) return found; + } + return document.getElementById(id); + } + + function activeElementDeep() { + let active = document.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; + return active; + } + + window.__IMPECCABLE_LIVE_CHROME_CORE__ = { + version: 1, + adapter: window.__IMPECCABLE_LIVE_ADAPTER__ || 'dom', + mountContract: LIVE_CHROME_MOUNT_CONTRACT, + surfaces: LIVE_UI_SURFACES, + componentIds: LIVE_UI_COMPONENT_IDS, + root: liveUiRoot, + append: uiAppend, + appendStyle: uiAppendStyle, + getById: uiGetById, + activeElementDeep, + debugState: () => ({ + state, + currentSessionId, + expectedVariants, + arrivedVariants, + visibleVariant, + savedSession: loadSession(), + sourceFile: currentSourceFile, + previewFile: currentPreviewFile, + previewMode: currentPreviewMode, + barText: barEl?.textContent || null, + barConnected: !!barEl?.isConnected, + hasSvelteComponentSession: !!svelteComponentSession, + mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0, + pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver, + recoveryWaitingForAnchor, + evtSourceReadyState: evtSource ? evtSource.readyState : null, + }), + }; + // Modal-aware chrome: keep our floating UI clickable inside Radix / // Headless UI / vaul portals. // @@ -245,9 +374,9 @@ rootEl.addEventListener('focusin', stop); } - // --------------------------------------------------------------------------- + // // Highlight overlay - // --------------------------------------------------------------------------- + // function initHighlight() { highlightEl = document.createElement('div'); @@ -259,7 +388,7 @@ transition: HIGHLIGHT_TRANSITION, display: 'none', opacity: '0', }); - document.body.appendChild(highlightEl); + uiAppend(highlightEl); tooltipEl = document.createElement('div'); tooltipEl.id = PREFIX + '-tooltip'; @@ -273,7 +402,7 @@ letterSpacing: '0.02em', transition: TOOLTIP_TRANSITION, }); - document.body.appendChild(tooltipEl); + uiAppend(tooltipEl); } function showHighlight(el) { @@ -310,7 +439,7 @@ if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; } } - // --------------------------------------------------------------------------- + // // Annotation overlay (comment pins + kinpaku strokes) // // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned @@ -318,7 +447,7 @@ // drag) drops a comment pin; drag paints a kinpaku SVG stroke. All coords // are stored in element-local CSS px so they survive scroll / resize and // correlate directly with the captured PNG. - // --------------------------------------------------------------------------- + // const DRAG_THRESHOLD = 5; // px - below this, treat pointerup as a click const PIN_DBL_CLICK_MS = 300; // two clicks on the same pin within this delete it @@ -397,7 +526,7 @@ annotOverlayEl.addEventListener('pointermove', onAnnotMove); annotOverlayEl.addEventListener('pointerup', onAnnotUp); annotOverlayEl.addEventListener('pointercancel', onAnnotUp); - document.body.appendChild(annotOverlayEl); + uiAppend(annotOverlayEl); // Modal-host friendliness: pointer-events is already 'auto' on this // overlay; we only need to silence the host's outside-interaction // listeners. Don't override pointer-events here (the overlay toggles @@ -828,9 +957,9 @@ return wrap; } - // --------------------------------------------------------------------------- + // // Element context extraction - // --------------------------------------------------------------------------- + // function stripManualEditRuntimeState(root) { if (!root || root.nodeType !== 1) return; @@ -971,9 +1100,9 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } - // --------------------------------------------------------------------------- + // // The Bar - one floating element, three modes - // --------------------------------------------------------------------------- + // // Contextual-bar palette. Cached at init so every build*Row reads a // consistent set of colors; detectPageTheme runs once rather than on every @@ -1006,7 +1135,7 @@ padding: '6px', maxWidth: '520px', minWidth: '320px', }); - document.body.appendChild(barEl); + uiAppend(barEl); defangOutsideHandlers(barEl); } @@ -1041,6 +1170,8 @@ } function showBar(mode) { + barHideSeq += 1; + if (mode === 'cycling' && !ensureCyclingRenderable('show-bar')) return; barEl.innerHTML = ''; if (mode === 'configure') { barEl.appendChild(configureKind === 'insert' ? buildInsertConfigureRow() : buildConfigureRow()); @@ -1058,11 +1189,12 @@ function hideBar() { if (!barEl) return; + const hideSeq = ++barHideSeq; stopVoice({ suppressSubmit: true }); if (configureKind === 'insert') clearInsertPicking(); barEl.style.opacity = '0'; barEl.style.transform = 'translateY(6px)'; - setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); + setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250); hideActionPicker(); closeTunePopover(); if (state === 'EDITING') restoreInlineEditDrafts(); @@ -1071,6 +1203,7 @@ function updateBarContent(mode) { if (!barEl || barEl.style.display === 'none') return; + if (mode === 'cycling' && !ensureCyclingRenderable('update-bar')) return; barEl.innerHTML = ''; // Reset bar styling to the kinpaku picker palette barEl.style.background = BP.surface; @@ -1090,13 +1223,13 @@ syncPageChatFocus('update-bar-content'); } - // --- Configure row --- + // Configure row function syncConfigureInputChrome() { - const wrap = document.getElementById(PREFIX + '-configure-input-wrap'); - const input = document.getElementById(PREFIX + '-input'); + const wrap = uiGetById(PREFIX + '-configure-input-wrap'); + const input = uiGetById(PREFIX + '-input'); if (!wrap || !input) return; - const focused = document.activeElement === input; + const focused = activeElementDeep() === input; wrap.dataset.inputFocused = focused ? 'true' : 'false'; wrap.dataset.voiceListening = (voiceListening && voiceCtx?.mode === 'configure') ? 'true' : 'false'; wrap.style.borderColor = (voiceListening && voiceCtx?.mode === 'configure') @@ -1104,7 +1237,7 @@ : (focused ? BP.accentSoft : BP.hairline); } - // --- Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) --- + // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs) function detectInsertAxisFromStyle(style) { const display = style?.display || 'block'; @@ -1377,7 +1510,7 @@ display: 'none', opacity: '0.9', }); - document.body.appendChild(insertLineEl); + uiAppend(insertLineEl); defangOutsideHandlers(insertLineEl); return insertLineEl; } @@ -1440,6 +1573,10 @@ /** Element used to position the floating bar / shader during a session. */ function resolveBarAnchor() { + if (svelteComponentSession?.sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor) return anchor; + } if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (wrapper) { @@ -1557,6 +1694,11 @@ positionBar(); } + function showOrUpdateCyclingBar() { + if (barEl && barEl.style.display !== 'none') updateBarContent('cycling'); + else showBar('cycling'); + } + function buildPlaceholderResizeHandles() { if (!placeholderResizeLayerEl) return; placeholderResizeLayerEl.innerHTML = ''; @@ -1665,7 +1807,7 @@ } function isInsertCreateEnabled(btn) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); + btn = btn || uiGetById(PREFIX + '-insert-create'); return !!btn && btn.getAttribute('aria-disabled') !== 'true'; } @@ -1691,7 +1833,7 @@ lineHeight: '1.35', }); insertCreateTooltipEl.id = PREFIX + '-insert-create-tooltip'; - document.body.appendChild(insertCreateTooltipEl); + uiAppend(insertCreateTooltipEl); return insertCreateTooltipEl; } @@ -1723,8 +1865,8 @@ } function syncInsertCreateButton(btn, input) { - btn = btn || document.getElementById(PREFIX + '-insert-create'); - input = input || document.getElementById(PREFIX + '-insert-input'); + btn = btn || uiGetById(PREFIX + '-insert-create'); + input = input || uiGetById(PREFIX + '-insert-input'); if (!btn || !input) return; const gate = insertCreateGateState(input); const ok = canCreateInsert(gate); @@ -1833,7 +1975,7 @@ voiceBtn.style.cursor = controlsLocked ? 'not-allowed' : 'pointer'; voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; - if (!document.getElementById(PREFIX + '-configure-input-style')) { + if (!uiGetById(PREFIX + '-configure-input-style')) { const s = document.createElement('style'); s.id = PREFIX + '-configure-input-style'; s.textContent = @@ -1842,7 +1984,7 @@ '#' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: impeccable-configure-voice-pulse 1.1s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-configure-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-configure-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } input.addEventListener('focus', () => syncConfigureInputChrome()); @@ -1949,6 +2091,9 @@ transition: 'border-color 0.15s ease', }); inputWrap.id = PREFIX + '-insert-input-wrap'; + inputWrap.addEventListener('pointerdown', (e) => e.stopPropagation()); + inputWrap.addEventListener('mousedown', (e) => e.stopPropagation()); + inputWrap.addEventListener('click', (e) => e.stopPropagation()); const input = document.createElement('input'); input.id = PREFIX + '-insert-input'; @@ -1984,6 +2129,12 @@ voiceBtn.style.opacity = controlsLocked ? '0.58' : '1'; input.addEventListener('input', () => syncInsertCreateButton()); + input.addEventListener('pointerdown', (e) => e.stopPropagation()); + input.addEventListener('mousedown', (e) => e.stopPropagation()); + input.addEventListener('click', (e) => { + e.stopPropagation(); + try { input.focus({ preventScroll: true }); } catch { input.focus(); } + }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); @@ -2049,6 +2200,7 @@ }); create.addEventListener('mouseleave', hideInsertCreateTooltip); create.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); if (controlsLocked) { showManualApplyBusyToast(); return; } if (!isInsertCreateEnabled(create)) return; @@ -2060,7 +2212,7 @@ return row; } - // --- Generating row --- + // Generating row function buildGeneratingRow() { const row = el('div', { @@ -2086,19 +2238,24 @@ }); // Variants currently arrive atomically in a single file edit, so a // per-variant counter would lie. Say what's true. - status.textContent = arrivedVariants < expectedVariants - ? 'Generating ' + expectedVariants + ' variants...' - : 'Done'; + status.textContent = recoveryWaitingForAnchor + ? 'Variants ready. Reveal the selected element to resume.' + : (arrivedVariants < expectedVariants + ? 'Generating ' + expectedVariants + ' variants...' + : 'Done'); row.appendChild(status); return row; } - // --- Cycling row --- + // Cycling row const TUNE_ICON_SVG = ''; function buildCyclingRow() { + if (!ensureCyclingRenderable('build-cycling-row')) { + return el('div', { display: 'none' }); + } const row = el('div', { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 2px', @@ -2106,6 +2263,7 @@ // Prev const prev = navBtn('\u2190'); + prev.id = PREFIX + '-variant-prev'; prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); }); if (visibleVariant <= 1) prev.style.opacity = '0.3'; row.appendChild(prev); @@ -2118,11 +2276,13 @@ fontFamily: MONO, fontSize: '11px', fontWeight: '500', color: BP.textDim, minWidth: '24px', textAlign: 'center', }); + counter.id = PREFIX + '-variant-counter'; counter.textContent = visibleVariant + '/' + arrivedVariants; row.appendChild(counter); // Next const next = navBtn('\u2192'); + next.id = PREFIX + '-variant-next'; next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); }); if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3'; row.appendChild(next); @@ -2208,9 +2368,9 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders - // --- Saving row (waiting for agent to process accept/discard) --- + // Saving row (waiting for agent to process accept/discard) function buildSavingRow() { const row = el('div', { @@ -2235,7 +2395,7 @@ return row; } - // --- Confirmed row (green success, auto-dismisses) --- + // Confirmed row (green success, auto-dismisses) function buildConfirmedRow() { const row = el('div', { @@ -2256,7 +2416,7 @@ return row; } - // --- Shared UI builders --- + // Shared UI builders function buildDots(clickable) { const container = el('div', { @@ -2290,10 +2450,7 @@ const idx = i; dot.addEventListener('click', (e) => { e.stopPropagation(); - visibleVariant = idx; - showVariantInDOM(currentSessionId, idx); - updateSelectedElement(); - updateBarContent('cycling'); + selectVariant(idx, 'variant_changed'); }); } container.appendChild(dot); @@ -2323,13 +2480,14 @@ function el(tag, styles) { const e = document.createElement(tag); + if (String(tag).toLowerCase() === 'button') e.type = 'button'; if (styles) Object.assign(e.style, styles); return e; } - // --------------------------------------------------------------------------- + // // Action picker popover - // --------------------------------------------------------------------------- + // function initActionPicker() { const P = barPaletteForTheme(detectPageTheme()); @@ -2384,16 +2542,20 @@ chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent'; }); chip.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + const prompt = uiGetById(PREFIX + '-input')?.value || ''; selectedAction = action.value; hideActionPicker(); updateBarContent('configure'); + const input = uiGetById(PREFIX + '-input'); + if (input && prompt) input.value = prompt; }); grid.appendChild(chip); }); pickerEl.appendChild(grid); - document.body.appendChild(pickerEl); + uiAppend(pickerEl); defangOutsideHandlers(pickerEl); // Cache the palette on the picker so toggleActionPicker's state refresh @@ -2433,7 +2595,33 @@ setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180); } - // --------------------------------------------------------------------------- + function ensureCyclingRenderable(reason) { + if (arrivedVariants > 0) { + if (visibleVariant < 1 || visibleVariant > arrivedVariants) visibleVariant = 1; + return true; + } + recoverEmptyCycling(reason); + return false; + } + + function recoverEmptyCycling(reason) { + if (recoveringEmptyCycling) return; + recoveringEmptyCycling = true; + try { + console.warn('[impeccable] Refusing to render empty variant cycling state:', reason); + const message = 'No variants were mounted. Please try again.'; + if (svelteComponentSession?.sessionId === currentSessionId) { + abortSvelteComponentInjection(currentSessionId, message); + return; + } + cleanup(); + showToast(message, 5000); + } finally { + recoveringEmptyCycling = false; + } + } + + // // Params panel (per-variant coarse controls) // // Variants may declare a parameter manifest via a JSON attribute on the @@ -2446,13 +2634,13 @@ // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped // CSS can respond instantly without regeneration: // - // range / numeric toggle → CSS var (`--p-`) used via var(--p-foo, N) + // range / numeric toggle -> CSS custom property used by variant styles // steps / boolean toggle → data-p- attribute used via :scope[data-p-foo="..."] // // On variant switch, values reset to that variant's declared defaults. // On accept, current values are sent in the event payload so the agent // can bake them into the source-file write. - // --------------------------------------------------------------------------- + // let paramsPanelEl = null; // outer wrapper (overflow:hidden, clips the slide) let paramsPanelInner = null; // translating content (carries bg, padding, knobs) @@ -2507,7 +2695,7 @@ }); paramsPanelEl.appendChild(paramsPanelBody); - document.body.appendChild(paramsPanelEl); + uiAppend(paramsPanelEl); // Don't override pointer-events: the panel toggles between 'none' (closed, // click-through) and 'auto' (open) on its own. Just silence the host's // outside-interaction listeners while the panel is open. @@ -2516,14 +2704,40 @@ } + function getMountedSvelteComponentAnchor(session = svelteComponentSession) { + const el = session?.mountTargetEl?.firstElementChild || null; + if (!el || !document.body.contains(el)) return null; + return rectIsUsableAnchor(el.getBoundingClientRect()) ? el : null; + } + + function resolveSvelteComponentAnchor(session = svelteComponentSession) { + return getMountedSvelteComponentAnchor(session) + || session?.swapAnchor + || null; + } + function getVisibleVariantEl() { if (!currentSessionId) return null; + if (svelteComponentSession?.sessionId === currentSessionId) { + return resolveSvelteComponentAnchor() + || svelteComponentSession.wrapperEl + || null; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return null; return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); } function parseVariantParams(variantEl) { + // Svelte component variants can't carry a `data-impeccable-params` attribute: + // the compiler reads `{` inside attribute values as expression delimiters, so + // JSON-with-braces breaks the build. For that path the params live in a sidecar + // params.json keyed by variant number, loaded into the session at mount time. + if (svelteComponentSession?.sessionId === currentSessionId) { + const byVariant = svelteComponentSession.paramsByVariant || {}; + const params = byVariant[String(visibleVariant)] || byVariant[visibleVariant]; + return Array.isArray(params) ? params : []; + } if (!variantEl) return []; const raw = variantEl.getAttribute('data-impeccable-params'); if (!raw) return []; @@ -2685,11 +2899,11 @@ } } - // --------------------------------------------------------------------------- + // // Inline text editing - makes pure-text descendants of the picked element // directly contenteditable. Save stages copy edits in the live buffer; the // Apply copy edits dock later asks the AI to apply the staged batch. - // --------------------------------------------------------------------------- + // let inlineEditRows = []; let inlineEditDrafts = new Map(); @@ -2803,7 +3017,7 @@ function disableInlineEdit(opts = {}) { for (const row of inlineEditRows) { - if (document.activeElement === row.el) row.el.blur(); + if (activeElementDeep() === row.el) row.el.blur(); row.el.removeAttribute('contenteditable'); delete row.el.dataset.impeccableEditable; delete row.el.dataset.impeccableOriginalText; @@ -3133,7 +3347,7 @@ if (detail.includes('newText cannot contain') || detail.includes('newText cannot be empty')) { showToast('Save rejected: ' + detail.replace(/^manual_edits:\s*/, ''), 5500); } else { - showToast('Save failed: retry or cancel', 4000); + showToast('Save failed - retry or cancel', 4000); } } } @@ -3181,11 +3395,11 @@ } function ensureSpinKeyframes() { - if (document.getElementById(PREFIX + '-keyframes')) return; + if (uiGetById(PREFIX + '-keyframes')) return; const style = document.createElement('style'); style.id = PREFIX + '-keyframes'; style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }'; - document.head.appendChild(style); + uiAppendStyle(style); } function pendingApplyLabel(count) { @@ -3318,10 +3532,10 @@ closeTunePopover(); } if (barEl && barEl.style.display !== 'none' && state === 'CONFIGURING') { - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value : ''; updateBarContent('configure'); - const nextInput = document.getElementById(PREFIX + '-input'); + const nextInput = uiGetById(PREFIX + '-input'); if (nextInput) nextInput.value = prompt; } if (editBadgeEl && editBadgeEl.style.display !== 'none') { @@ -3455,19 +3669,19 @@ updatePendingCounter(remaining); if (result.failed && result.failed.length > 0) { console.warn('[impeccable] some copy edits failed:', result.failed); - showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed, see console', 5000); + showToast('Applied ' + (result.applied?.length || 0) + ', ' + result.failed.length + ' failed - see console', 5000); } else { const n = Array.isArray(result.applied) ? result.applied.length : (result.cleared || 0); if (n > 0) { showToast('Applied ' + n + ' edit' + (n === 1 ? '' : 's'), 2500); } else { console.warn('[impeccable] apply returned no verified edits:', result); - showToast('No edits applied, see console', 4000); + showToast('No edits applied - see console', 4000); } } } catch (err) { console.error('[impeccable] commit failed:', err); - showToast('Apply failed, see console', 4000); + showToast('Apply failed - see console', 4000); } finally { if (waitForSseCompletion) return; const remainingCount = parseInt(pendingPillEl?.dataset.count || '0', 10) || 0; @@ -3497,7 +3711,7 @@ } } catch (err) { console.error('[impeccable] discard failed:', err); - showToast('Discard failed, see console', 4000); + showToast('Discard failed - see console', 4000); } } @@ -3645,7 +3859,7 @@ const failedCount = numberOrNull(msg.failedCount) || 0; const appliedCount = numberOrNull(msg.appliedCount) || numberOrNull(msg.cleared) || 0; if (failedCount > 0) { - showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed, see console', 5000); + showToast('Applied ' + appliedCount + ', ' + failedCount + ' failed - see console', 5000); } else if (appliedCount > 0) { showToast('Applied ' + appliedCount + ' edit' + (appliedCount === 1 ? '' : 's'), 2500); } @@ -3799,9 +4013,164 @@ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); } - // --------------------------------------------------------------------------- + // // Edit content badge - floating button at element top-right to enter EDITING mode - // --------------------------------------------------------------------------- + // + + function usesShadowChromeRoot() { + const root = liveUiRoot(); + return root && root !== document.body && root.host && root.host.id === PREFIX + '-root'; + } + + function setImportantStyle(el, name, value) { + el.style.setProperty(name, value, 'important'); + } + + function initEditBadgeHitProxies() { + if (!usesShadowChromeRoot() || editBadgeProxyRoot) return; + editBadgeProxyRoot = document.createElement('div'); + editBadgeProxyRoot.id = PREFIX + '-edit-badge-hit-proxies'; + editBadgeProxyRoot.setAttribute('aria-hidden', 'true'); + const styles = { + all: 'initial', + position: 'fixed', + inset: '0', + width: '100vw', + height: '100vh', + zIndex: String(Z.toast + 1), + pointerEvents: 'none', + background: 'transparent', + overflow: 'visible', + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(editBadgeProxyRoot, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + document.body.appendChild(editBadgeProxyRoot); + } + + function styleEditBadgeProxy(proxy, target) { + const rect = target.getBoundingClientRect(); + const cursor = getComputedStyle(target).cursor || 'pointer'; + const styles = { + all: 'initial', + position: 'fixed', + left: rect.left + 'px', + top: rect.top + 'px', + width: rect.width + 'px', + height: rect.height + 'px', + margin: '0', + padding: '0', + border: '0', + borderRadius: '0', + background: 'transparent', + color: 'transparent', + opacity: '0.001', + pointerEvents: 'auto', + cursor, + zIndex: String(Z.toast + 2), + }; + for (const [name, value] of Object.entries(styles)) { + setImportantStyle(proxy, name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), value); + } + } + + function proxyMouseEvent(type, source, target) { + let event; + try { + event = new MouseEvent(type, { + bubbles: type !== 'mouseenter' && type !== 'mouseleave', + cancelable: true, + composed: true, + clientX: source.clientX, + clientY: source.clientY, + screenX: source.screenX, + screenY: source.screenY, + button: source.button || 0, + buttons: source.buttons || 0, + ctrlKey: source.ctrlKey, + metaKey: source.metaKey, + shiftKey: source.shiftKey, + altKey: source.altKey, + }); + target.dispatchEvent(event); + } catch {} + } + + function bindEditBadgeProxy(proxy, target) { + const stop = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + proxy.addEventListener('mouseenter', (event) => { + stop(event); + proxyMouseEvent('mouseenter', event, target); + proxyMouseEvent('mouseover', event, target); + }); + proxy.addEventListener('mouseleave', (event) => { + stop(event); + proxyMouseEvent('mouseleave', event, target); + proxyMouseEvent('mouseout', event, target); + }); + proxy.addEventListener('mousedown', (event) => { + stop(event); + target.focus?.({ preventScroll: true }); + proxyMouseEvent('mousedown', event, target); + }); + proxy.addEventListener('mouseup', (event) => { + stop(event); + proxyMouseEvent('mouseup', event, target); + }); + proxy.addEventListener('click', (event) => { + stop(event); + target.click(); + syncEditBadgeHitProxies(); + }); + } + + function editBadgeProxyTargets() { + if (!usesShadowChromeRoot() || !editBadgeEl || editBadgeEl.style.display === 'none') return []; + return [...editBadgeEl.querySelectorAll('button')].filter((target) => { + if (target.disabled) return false; + const rect = target.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return false; + const style = getComputedStyle(target); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); + } + + function syncEditBadgeHitProxies() { + if (!usesShadowChromeRoot()) { + if (editBadgeProxyRoot) editBadgeProxyRoot.remove(); + editBadgeProxyRoot = null; + editBadgeProxyByTarget = new Map(); + return; + } + initEditBadgeHitProxies(); + if (!editBadgeProxyRoot) return; + const targets = editBadgeProxyTargets(); + const active = new Set(targets); + for (const [target, proxy] of editBadgeProxyByTarget) { + if (!active.has(target) || !target.isConnected) { + proxy.remove(); + editBadgeProxyByTarget.delete(target); + } + } + for (const target of targets) { + let proxy = editBadgeProxyByTarget.get(target); + if (!proxy) { + proxy = document.createElement('button'); + proxy.type = 'button'; + proxy.tabIndex = -1; + proxy.dataset.impeccableEditBadgeProxy = 'true'; + proxy.setAttribute('aria-hidden', 'true'); + bindEditBadgeProxy(proxy, target); + editBadgeProxyRoot.appendChild(proxy); + editBadgeProxyByTarget.set(target, proxy); + } + proxy.title = target.title || target.textContent || 'Edit copy'; + styleEditBadgeProxy(proxy, target); + } + } function initEditBadge() { editBadgeEl = document.createElement('div'); @@ -3813,10 +4182,11 @@ display: 'none', userSelect: 'none', }); - document.body.appendChild(editBadgeEl); + uiAppend(editBadgeEl); + initEditBadgeHitProxies(); // Remove focus rings on edit badge buttons + contenteditable elements - if (!document.getElementById(PREFIX + '-edit-badge-focus-style')) { + if (!uiGetById(PREFIX + '-edit-badge-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-edit-badge-focus-style'; s.textContent = @@ -3826,21 +4196,26 @@ '[data-impeccable-editable="true"] { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus { outline: none !important; box-shadow: none !important; }' + '[data-impeccable-editable="true"]:focus-visible { outline: none !important; box-shadow: none !important; }'; - document.head.appendChild(s); + uiAppendStyle(s); } } function positionEditBadge() { - if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') return; + if (!selectedElement || !editBadgeEl || editBadgeEl.style.display === 'none') { + syncEditBadgeHitProxies(); + return; + } const r = selectedElement.getBoundingClientRect(); const bw = editBadgeEl.offsetWidth; editBadgeEl.style.top = Math.max(4, r.top - 28) + 'px'; editBadgeEl.style.left = Math.min(window.innerWidth - bw - 4, r.right - bw) + 'px'; + syncEditBadgeHitProxies(); } function renderEditBadge(mode) { if (mode === 'hidden' || !editBadgeEl) { if (editBadgeEl) editBadgeEl.style.display = 'none'; + syncEditBadgeHitProxies(); return; } editBadgeEl.style.display = 'flex'; @@ -4047,7 +4422,7 @@ barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN; } // Re-render the bar so the Tune chip picks up the active styling. - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } function closeTunePopover() { @@ -4055,13 +4430,13 @@ hideParamsPanel(); if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT; if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') { - updateBarContent('cycling'); + showOrUpdateCyclingBar(); } } - // --------------------------------------------------------------------------- + // // Variant cycling in DOM - // --------------------------------------------------------------------------- + // function isVariantShown(el) { if (!el) return false; @@ -4081,9 +4456,42 @@ } } - function showVariantInDOM(sessionId, num) { + function scheduleCyclingBarSync(sessionId, variantNum) { + requestAnimationFrame(() => { + if (state !== 'CYCLING') return; + if (currentSessionId !== sessionId) return; + if (visibleVariant !== variantNum) return; + showOrUpdateCyclingBar(); + syncCyclingControls(); + positionBar(); + }); + } + + function syncCyclingControls() { + const shown = svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0 + ? svelteComponentSession.mountedVariant + : visibleVariant; + const counter = uiGetById(PREFIX + '-variant-counter'); + if (counter && arrivedVariants > 0) counter.textContent = shown + '/' + arrivedVariants; + const prev = uiGetById(PREFIX + '-variant-prev'); + const next = uiGetById(PREFIX + '-variant-next'); + if (prev) prev.style.opacity = shown <= 1 ? '0.3' : '1'; + if (next) next.style.opacity = shown >= arrivedVariants ? '0.3' : '1'; + if (currentSessionId && state === 'CYCLING') saveSession(); + } + + async function showVariantInDOM(sessionId, num) { + if (svelteComponentSession?.sessionId === sessionId) { + visibleVariant = num; + const mounted = await mountSvelteComponentVariant(num); + if (!mounted) return false; + updateSelectedElement(); + refreshParamsPanel(); + scheduleCyclingBarSync(sessionId, num); + return true; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); - if (!wrapper) return; + if (!wrapper) return false; for (const child of wrapper.children) { const v = child.dataset ? child.dataset.impeccableVariant : null; if (!v) continue; @@ -4093,6 +4501,378 @@ // CYCLING yet, the subsequent CYCLING transition triggers its own // refresh) and every cycle step. refreshParamsPanel(); + return true; + } + + function isSvelteComponentManifestPath(filePath) { + return String(filePath || '').endsWith('manifest.json'); + } + + function parseOriginalMarkupElement(originalMarkup) { + const parser = new DOMParser(); + const doc = parser.parseFromString('
' + originalMarkup + '
', 'text/html'); + return doc.getElementById('impeccable-anchor')?.firstElementChild || null; + } + + function findLiveElementForOriginalMarkup(originalMarkup) { + const origContent = parseOriginalMarkupElement(originalMarkup); + if (!origContent) return null; + + const tag = origContent.tagName.toLowerCase(); + const cls = origContent.className; + let liveEl = null; + if (origContent.id) { + liveEl = document.getElementById(origContent.id); + } else if (cls) { + const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); + for (const c of candidates) { + if (c.className === cls && !own(c)) { liveEl = c; break; } + } + if (!liveEl) { + const expectedClasses = String(cls).split(/\s+/).filter(Boolean); + for (const c of candidates) { + if (own(c)) continue; + if (expectedClasses.every((name) => c.classList.contains(name))) { liveEl = c; break; } + } + } + } + return liveEl; + } + + function isSvelteInsertManifest(manifest) { + return manifest?.previewMode === 'svelte-component' && manifest?.mode === 'insert'; + } + + function findLiveElementForSvelteManifest(manifest) { + if (isSvelteInsertManifest(manifest)) { + const anchor = findInsertAnchorInDom(); + if (anchor?.parentElement) return anchor; + } + return findLiveElementForOriginalMarkup(manifest?.originalMarkup || manifest?.anchorMarkup || ''); + } + + function loadSvelteRuntime(runtimeModule) { + const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js'; + const url = new URL(modulePath, location.origin).href; + if (!svelteRuntimePromise) { + svelteRuntimePromise = import(/* @vite-ignore */ url); + } + return svelteRuntimePromise; + } + + // Svelte component variants declare their params in a sidecar params.json under + // componentDir (keyed by variant number), because a `data-impeccable-params` + // attribute with JSON braces can't survive the Svelte compiler. Returns a map of + // { "1": [...params], "2": [...] }; an empty object when the agent declared none. + async function loadSvelteComponentParams(manifest) { + const dir = String(manifest?.componentDir || '').replace(/^\/+/, ''); + if (!dir) return {}; + const paramsPath = dir + '/params.json'; + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(paramsPath); + try { + const res = await fetch(url); + if (!res.ok) return {}; + const parsed = JSON.parse(await res.text()); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out = {}; + for (const [key, value] of Object.entries(parsed)) { + if (Array.isArray(value)) out[String(key)] = value; + } + return out; + } catch { + return {}; + } + } + + function buildSveltePropValuesFromLiveElement(liveEl, manifest) { + const contract = manifest?.propContract || []; + const values = {}; + if (!liveEl || contract.length === 0) return values; + const sourceOriginal = parseOriginalMarkupElement(manifest.originalMarkup || ''); + if (!sourceOriginal) return values; + const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl); + for (const entry of contract) { + const token = '{' + entry.expr + '}'; + values[entry.prop] = map.get(token) || ''; + } + return values; + } + + async function mountSvelteComponentVariant(variantNum) { + if (!svelteComponentSession || !variantNum) return false; + const { manifest, mountTargetEl, sessionId } = svelteComponentSession; + try { + const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement; + svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null; + const runtime = await loadSvelteRuntime(manifest.runtimeModule); + const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte'; + const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now(); + const mod = await import(/* @vite-ignore */ moduleUrl); + const Component = mod.default; + if (svelteComponentSession.mountedInstance && runtime.unmount) { + await runtime.unmount(svelteComponentSession.mountedInstance); + svelteComponentSession.mountedInstance = null; + } + svelteComponentSession.mountedInstance = runtime.mount(Component, { + target: mountTargetEl, + props: { ...svelteComponentSession.propValues }, + intro: false, + }); + svelteComponentSession.mountedVariant = variantNum; + svelteComponentSession.runtime = runtime; + if (state === 'CYCLING') syncCyclingControls(); + const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (nextAnchor) { + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(nextAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = nextAnchor; + } else { + requestAnimationFrame(() => { + if (svelteComponentSession?.sessionId !== sessionId) return; + const settledAnchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!settledAnchor) return; + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(settledAnchor, manifest.originalMarkup || ''); + } + svelteComponentSession.swapAnchor = null; + selectedElement = settledAnchor; + }); + } + return true; + } catch (err) { + if (svelteComponentSession?.sessionId === sessionId) { + svelteComponentSession.swapAnchor = null; + } + console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err); + return false; + } + } + + function teardownSvelteComponentSession(restoreOriginal) { + if (!svelteComponentSession) return; + const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession; + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + if (restoreOriginal && detachedOriginal && wrapperEl?.parentElement) { + wrapperEl.parentElement.replaceChild(detachedOriginal, wrapperEl); + } else if (wrapperEl?.parentElement) { + wrapperEl.remove(); + } + svelteComponentSession = null; + svelteRuntimePromise = null; + } + + function applyOriginalAttrsToSvelteAnchor(el, originalMarkup) { + if (!el || !originalMarkup) return; + const original = parseOriginalMarkupElement(originalMarkup); + if (!original || original.tagName !== el.tagName) return; + for (const attr of original.attributes) { + if (attr.name === 'class') { + for (const className of attr.value.split(/\s+/).filter(Boolean)) { + el.classList.add(className); + } + } else if (!el.hasAttribute(attr.name)) { + el.setAttribute(attr.name, attr.value); + } + } + } + + function commitAcceptedSvelteComponentToDom(sessionId) { + if (!svelteComponentSession || svelteComponentSession.sessionId !== sessionId) return false; + const { wrapperEl, runtime, mountedInstance, manifest } = svelteComponentSession; + const anchor = getMountedSvelteComponentAnchor(svelteComponentSession); + if (!anchor || !wrapperEl?.parentElement) return false; + const committed = anchor.cloneNode(true); + if (!isSvelteInsertManifest(manifest)) { + applyOriginalAttrsToSvelteAnchor(committed, manifest.originalMarkup || ''); + } + if (mountedInstance && runtime?.unmount) { + try { runtime.unmount(mountedInstance); } catch { /* non-fatal */ } + } + wrapperEl.parentElement.replaceChild(committed, wrapperEl); + svelteComponentSession = null; + svelteRuntimePromise = null; + selectedElement = committed; + return true; + } + + async function injectSvelteComponentsFromManifest(manifestPath, sessionId) { + const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(manifestPath); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + const manifest = JSON.parse(await res.text()); + if (manifest.id !== sessionId) return; + + const paramsByVariant = await loadSvelteComponentParams(manifest); + currentSessionId = sessionId; + expectedVariants = Number(manifest.count) || expectedVariants || 1; + rememberSessionFileMeta({ + sourceFile: manifest.sourceFile, + previewFile: manifestPath, + previewMode: 'svelte-component', + }); + if (state !== 'CYCLING') state = 'GENERATING'; + + const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (existingWrapper && svelteComponentSession?.sessionId === sessionId) { + recoveryWaitingForAnchor = false; + svelteComponentSession.paramsByVariant = paramsByVariant; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1; + await mountSvelteComponentVariant(visibleVariant || 1); + state = 'CYCLING'; + showOrUpdateCyclingBar(); + saveSession(); + return; + } + + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) { + console.warn('[impeccable] Could not find original element in live DOM.'); + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants + ? visibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + saveSession(); + queueCheckpoint('svelte_component_anchor_missing'); + waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }); + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return; + } + + const wrapper = document.createElement('div'); + wrapper.dataset.impeccableVariants = sessionId; + wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1); + wrapper.dataset.impeccablePreview = 'svelte-component'; + wrapper.style.display = 'contents'; + + const mountTarget = document.createElement('div'); + mountTarget.dataset.impeccableComponentMount = sessionId; + mountTarget.style.display = 'contents'; + wrapper.appendChild(mountTarget); + + const insertMode = isSvelteInsertManifest(manifest); + const detachedOriginal = insertMode ? null : liveEl; + if (insertMode) { + removeInsertPlaceholderDom(); + if (manifest.position === 'before') liveEl.parentElement.insertBefore(wrapper, liveEl); + else liveEl.parentElement.insertBefore(wrapper, liveEl.nextSibling); + } else { + liveEl.parentElement.replaceChild(wrapper, liveEl); + } + + svelteComponentSession = { + sessionId, + manifest, + insertMode, + wrapperEl: wrapper, + mountTargetEl: mountTarget, + detachedOriginal, + mountedInstance: null, + mountedVariant: 0, + runtime: null, + propValues: buildSveltePropValuesFromLiveElement(detachedOriginal, manifest), + paramsByVariant, + }; + if (pendingSvelteComponentRetryObserver) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + } + recoveryWaitingForAnchor = false; + + const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0; + arrivedVariants = Number(manifest.count) || expectedVariants || 1; + expectedVariants = arrivedVariants; + const saved = loadSession(); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants + ? previousVisibleVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + + const mounted = await mountSvelteComponentVariant(visibleVariant); + if (!mounted) { + // The compiled component threw (e.g. a Svelte compile error in the + // variant file). Don't strand the bar in an empty CYCLING state; restore + // the original element and reset to PICKING so the user can retry. + abortSvelteComponentInjection(sessionId, 'A variant failed to compile. Fix the component and re-run.'); + return; + } + + selectedElement = mountTarget.firstElementChild || mountTarget; + state = 'CYCLING'; + recoveryWaitingForAnchor = false; + hideShaderOverlay(); + showOrUpdateCyclingBar(); + disableInlineEdit(); + refreshParamsPanel(); + positionBar(); + saveSession(); + console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.'); + } catch (err) { + console.error('[impeccable] Failed to mount Svelte component variants:', err); + abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.'); + } + } + + function waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest }) { + if (pendingSvelteComponentRetryObserver) pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = new MutationObserver(() => { + if (svelteComponentSession?.sessionId === sessionId) { + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + return; + } + const liveEl = findLiveElementForSvelteManifest(manifest); + if (!liveEl?.parentElement) return; + pendingSvelteComponentRetryObserver.disconnect(); + pendingSvelteComponentRetryObserver = null; + injectSvelteComponentsFromManifest(manifestPath, sessionId); + }); + pendingSvelteComponentRetryObserver.observe(document.body, { childList: true, subtree: true }); + } + + // Reset cleanly when a Svelte component session can't mount: tear the wrapper + // down (restoring the original element), clear persisted session state, and + // return the bar to PICKING. Avoids the stuck 0/0 CYCLING bar. + function abortSvelteComponentInjection(sessionId, message) { + try { + if (svelteComponentSession?.sessionId === sessionId) { + teardownSvelteComponentSession(true); + } else { + const orphan = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (orphan) orphan.remove(); + } + } catch (err) { + console.warn('[impeccable] Svelte component abort cleanup failed:', err); + } + hideShaderOverlay(); + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } + if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; } + stopScrollLock(); + clearSession(); + clearHandled(); + resetSessionFileMeta(); + currentSessionId = null; + expectedVariants = 0; + arrivedVariants = 0; + visibleVariant = 0; + selectedElement = null; + state = 'PICKING'; + hideBar(); + if (message) showToast(message, 5000); } /** @@ -4101,6 +4881,11 @@ * This works even when the dev server caches HTML (Bun, static servers). */ function injectVariantsFromSource(filePath, sessionId) { + if (isSvelteComponentManifestPath(filePath)) { + injectSvelteComponentsFromManifest(filePath, sessionId); + return; + } + rememberSessionFileMeta({ file: filePath }); const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath); fetch(url) .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) @@ -4119,7 +4904,7 @@ const doc = parser.parseFromString(block, 'text/html'); srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!srcWrapper) { - console.error('[impeccable] Variant wrapper not found in source file.'); + console.warn('[impeccable] Variant wrapper not found in source file.'); return; } @@ -4134,31 +4919,31 @@ const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child'); if (!origContent) return; - const tag = origContent.tagName.toLowerCase(); - const cls = origContent.className; - let liveEl = null; - if (origContent.id) { - liveEl = document.getElementById(origContent.id); - } else if (cls) { - const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]); - for (const c of candidates) { - if (c.className === cls && !own(c)) { liveEl = c; break; } - } - } - + const liveEl = findLiveElementForOriginalMarkup(origContent.outerHTML); if (!liveEl) { - console.error('[impeccable] Could not find original element in live DOM.'); + console.warn('[impeccable] Could not find original element in live DOM.'); + selectedElement = document.body; + recoveryWaitingForAnchor = true; + state = 'GENERATING'; + showBar('generating'); + saveSession(); + showToast('Variants ready. Reveal the selected element to resume.', 15000); return; } liveEl.parentElement.replaceChild(wrapper, liveEl); } + recoveryWaitingForAnchor = false; // Update state: count variants, preserving the user's current variant // when a late HMR/source reinjection lands after they have cycled. const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); arrivedVariants = variants.length; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants); + if (arrivedVariants <= 0) { + recoverEmptyCycling('source-fallback-empty'); + return; + } const saved = loadSession(); const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants @@ -4170,8 +4955,9 @@ selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement; state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4184,21 +4970,129 @@ }); } - function cycleVariant(dir) { + function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) { + const map = new Map(); + if (!sourceOriginal || !liveOriginal) return map; + + const sourceNodes = collectTextNodes(sourceOriginal) + .filter((node) => /\{[^{}]+\}/.test(node.nodeValue || '')); + const liveTexts = collectTextNodes(liveOriginal) + .map((node) => normalizePreviewText(node.nodeValue || '')) + .filter(Boolean); + let liveIndex = 0; + + for (const sourceNode of sourceNodes) { + const sourceText = sourceNode.nodeValue || ''; + const tokens = sourceText.match(/\{[^{}]+\}/g) || []; + if (tokens.length === 0) continue; + + const liveText = liveTexts[liveIndex++] || ''; + if (!liveText) continue; + + if (tokens.length === 1) { + const token = tokens[0]; + const normalizedSource = normalizePreviewText(sourceText); + if (normalizedSource === token) { + map.set(token, liveText); + continue; + } + + const match = liveText.match(expressionTextMatcher(sourceText, [token])); + if (match && match[1]) map.set(token, match[1].trim()); + continue; + } + + if (normalizePreviewText(sourceText) === tokens.join(' ')) { + for (const token of tokens) { + const tokenLiveText = liveTexts[liveIndex - 1] || ''; + if (tokenLiveText) map.set(token, tokenLiveText); + } + } + } + + return map; + } + + function expressionTextMatcher(sourceText, tokens) { + let pattern = '^'; + let cursor = 0; + for (const token of tokens) { + const index = sourceText.indexOf(token, cursor); + if (index === -1) continue; + pattern += escapeRegExp(sourceText.slice(cursor, index)).replace(/\s+/g, '\\s*'); + pattern += '(.*?)'; + cursor = index + token.length; + } + pattern += escapeRegExp(sourceText.slice(cursor)).replace(/\s+/g, '\\s*') + '$'; + return new RegExp(pattern); + } + + function collectTextNodes(root) { + if (!root) return []; + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + nodes.push(node); + node = walker.nextNode(); + } + return nodes; + } + + function normalizePreviewText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + async function selectVariant(next, checkpointReason) { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } - const next = visibleVariant + dir; + if (variantSelectionInFlight) return; if (next < 1 || next > arrivedVariants) return; - visibleVariant = next; - showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself - updateSelectedElement(); - updateBarContent('cycling'); - positionBar(); - saveSession(); - queueCheckpoint('variant_changed'); + if (next === visibleVariant) return; + + const previous = visibleVariant; + variantSelectionInFlight = true; + const selectionPromise = (async () => { + visibleVariant = next; + showOrUpdateCyclingBar(); + saveSession(); + const shown = await showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself + if (!shown) { + visibleVariant = previous; + await showVariantInDOM(currentSessionId, previous); + showOrUpdateCyclingBar(); + saveSession(); + return; + } + updateSelectedElement(); + showOrUpdateCyclingBar(); + positionBar(); + saveSession(); + if (checkpointReason) queueCheckpoint(checkpointReason); + })(); + variantSelectionPromise = selectionPromise; + try { + await selectionPromise; + } finally { + if (variantSelectionPromise === selectionPromise) variantSelectionPromise = null; + variantSelectionInFlight = false; + } + } + + function cycleVariant(dir) { + selectVariant(visibleVariant + dir, 'variant_changed'); } function updateSelectedElement() { if (!currentSessionId) return; + if (svelteComponentSession?.sessionId === currentSessionId) { + const anchor = resolveSvelteComponentAnchor(); + if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; + return; + } const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (!wrapper) return; const visEl = pickVariantContent(wrapper, visibleVariant); @@ -4206,6 +5100,9 @@ } function readVisibleVariantFromDOM(sessionId) { + if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { + return svelteComponentSession.mountedVariant; + } const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!wrapper) return 0; const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -4341,9 +5238,9 @@ // scrollY that the next resume needs to read. } - // --------------------------------------------------------------------------- + // // MutationObserver for progressive variant reveal - // --------------------------------------------------------------------------- + // function startVariantObserver(sessionId) { let updating = false; // re-entrancy guard @@ -4426,10 +5323,11 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) { state = 'CYCLING'; + recoveryWaitingForAnchor = false; hideShaderOverlay(); if (wrapper.dataset.impeccableMode === 'insert') finalizeInsertSession(); updateSelectedElement(); - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); positionBar(); @@ -4445,9 +5343,9 @@ return obs; } - // --------------------------------------------------------------------------- + // // Bar scroll tracking - // --------------------------------------------------------------------------- + // function startScrollTracking() { function tick() { @@ -4483,10 +5381,10 @@ if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; } } - // --------------------------------------------------------------------------- + // // SSE (server→browser) + fetch POST (browser→server) // Zero-dependency replacement for WebSocket. - // --------------------------------------------------------------------------- + // let evtSource = null; let sseRetries = 0; @@ -4509,6 +5407,7 @@ console.log('[impeccable] Live mode connected.'); syncAgentPollingUi(!!msg.agentPolling); startAgentStatusPoll(); + restoreFromActiveSessions(msg.activeSessions, 'sse_connected'); if (state === 'IDLE' && (pickActive || insertActive)) state = 'PICKING'; syncPageChatFocus('sse-connected'); break; @@ -4531,11 +5430,12 @@ break; case 'done': if (maybeCompleteSteer(msg)) break; + rememberSessionFileMeta(msg); // Variants already arrived via HMR → normal transition. if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (state === 'GENERATING') { state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); disableInlineEdit(); refreshParamsPanel(); } @@ -4557,7 +5457,7 @@ if (arrivedVariants >= expectedVariants && expectedVariants > 0) return; if (state !== 'GENERATING') return; showToast( - "Variants ready. If the picked element isn't visible, retrace the path that revealed it; they'll appear automatically.", + "Variants ready. If the picked element isn't visible, retrace the path that revealed it - they'll appear automatically.", 15000, ); }, 2000); @@ -4571,12 +5471,18 @@ // the final complete event. Keep the browser in its recoverable // saving state while the source cleanup is still in flight. break; + case 'discarded': + if (msg.id && msg.id === currentSessionId) { + markSessionHandled(); + cleanup(); + } + break; case 'error': if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) { pendingAcceptedSession = null; state = 'CYCLING'; updateBarContent('cycling'); - showToast('Could not complete accept cleanup with the live server. Session kept for recovery; try Accept again.', 5000); + showToast('Could not complete accept cleanup. Try Accept again.', 5000); break; } if (maybeCompleteSteer(msg)) break; @@ -4656,6 +5562,9 @@ expectedVariants, arrivedVariants, visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, paramValues: { ...paramsCurrentValues }, }; } @@ -4665,6 +5574,20 @@ return sendEvent(checkpointPayload(reason)).catch(() => null); } + function sendSteerCheckpoint(id, reason, extra) { + if (!id) return Promise.resolve(null); + return sendEvent({ + type: 'checkpoint', + id, + revision: sessionState.nextCheckpointRevision(), + owner: browserOwner, + phase: 'steer', + reason, + pageUrl: location.pathname, + ...(extra || {}), + }).catch(() => null); + } + function queueCheckpoint(reason) { if (!currentSessionId) return; if (checkpointTimer) clearTimeout(checkpointTimer); @@ -4674,9 +5597,9 @@ }, 120); } - // --------------------------------------------------------------------------- + // // Event handlers - // --------------------------------------------------------------------------- + // function handleMouseMove(e) { if (pendingApplyInFlight) return; @@ -4860,7 +5783,7 @@ // // DISABLED: quick-Go workflows pay an extra harness round trip because // prefetch + generate arrive as two events instead of one. Re-enable with - // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to + // a browser-side debounce (~800-1000ms, cancelled on Go) if we want to // resurrect this. Server validator and skill dispatch remain in place so // flipping this flag is the only change needed. const PREFETCH_ENABLED = false; @@ -4876,6 +5799,14 @@ function handleKeyDown(e) { // When the annotation input is focused, let it handle its own keys. if (annotEditing && annotEditing.input && e.target === annotEditing.input) return; + const deepActive = activeElementDeep(); + if ( + deepActive + && own(deepActive) + && /^(INPUT|TEXTAREA|SELECT)$/.test(deepActive.tagName || '') + ) { + return; + } // While a contenteditable text-leaf is focused, let the browser handle // all keys except Escape. Escape cancels the current edit (restores // original text) and blurs without saving, staying in CONFIGURING. @@ -4982,7 +5913,7 @@ if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!selectedElement || state !== 'CONFIGURING') return; stopVoice({ suppressSubmit: true }); - const input = document.getElementById(PREFIX + '-input'); + const input = uiGetById(PREFIX + '-input'); const prompt = input ? input.value.trim() : ''; // Commit any pending pin edit BEFORE we snapshot annotations. @@ -4996,6 +5927,7 @@ expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); // Flip to GENERATING immediately so the bar morphs without waiting on // capture + upload. The event is emitted from captureAndEmit() once the @@ -5054,7 +5986,7 @@ function handleInsertCreate() { if (!placeholderElement || !insertAnchorElement || state !== 'CONFIGURING' || configureKind !== 'insert') return; - const input = document.getElementById(PREFIX + '-insert-input'); + const input = uiGetById(PREFIX + '-insert-input'); const prompt = input ? input.value.trim() : ''; if (annotEditing) finalizeEditingPin(); const snapshot = { @@ -5064,10 +5996,12 @@ if (!canCreateInsert({ prompt, comments: snapshot.comments, strokes: snapshot.strokes })) return; stopVoice({ suppressSubmit: true }); + pendingAcceptedSession = null; currentSessionId = id8(); expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; + resetSessionFileMeta(); selectedElement = placeholderElement; insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement); @@ -5107,9 +6041,9 @@ captureAndEmit(elForCapture, basePayload, snapshot, captureRect); } - // --------------------------------------------------------------------------- + // // Screenshot capture + upload - // --------------------------------------------------------------------------- + // let msLoadPromise = null; function loadModernScreenshot() { @@ -5120,7 +6054,7 @@ s.src = 'http://localhost:' + PORT + '/modern-screenshot.js'; s.onload = () => resolve(window.modernScreenshot); s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); }; - document.head.appendChild(s); + uiAppendStyle(s); }); return msLoadPromise; } @@ -5235,11 +6169,113 @@ return '#ffffff'; } + function captureChromeNodes() { + const nodes = []; + const add = (node) => { + if (!node || node === document.body || nodes.includes(node)) return; + nodes.push(node); + }; + add(document.getElementById(PREFIX + '-root')); + [ + PREFIX + '-highlight', + PREFIX + '-tooltip', + PREFIX + '-bar', + PREFIX + '-picker', + PREFIX + '-params-panel', + PREFIX + '-insert-line', + PREFIX + '-insert-placeholder', + PREFIX + '-insert-create-tooltip', + PREFIX + '-annot', + PREFIX + '-design-host', + PREFIX + '-toast', + PREFIX + '-shader', + ].forEach((id) => add(uiGetById(id))); + return nodes; + } + + async function hideCaptureChromeForShaderProxy(fn) { + const saved = captureChromeNodes().map((node) => ({ + node, + visibility: node.style.visibility, + priority: node.style.getPropertyPriority('visibility'), + })); + for (const { node } of saved) { + node.style.setProperty('visibility', 'hidden', 'important'); + } + await new Promise((resolve) => requestAnimationFrame(resolve)); + try { + return await fn(); + } finally { + for (const { node, visibility, priority } of saved) { + node.style.setProperty('visibility', visibility, priority); + } + } + } + + function shouldUseAncestorCropShaderProxy(el) { + // TODO: Enable this proxy for React/Vue/etc. adapters once their live + // preview mounts are covered by the same shader regression checks. + const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase(); + if (adapter === 'svelte' || adapter === 'sveltekit') return true; + if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true; + const wrapper = el?.closest?.('[data-impeccable-variants]'); + return wrapper?.dataset?.impeccablePreview === 'svelte-component'; + } + + function paintsShaderProxySurface(node) { + const s = getComputedStyle(node); + return !isTransparentColor(s.backgroundColor) + || (s.backgroundImage && s.backgroundImage !== 'none') + || paintsBackdrop(node); + } + + function findShaderProxyCaptureRoot(el) { + const doc = el.ownerDocument || document; + const er = el.getBoundingClientRect(); + let node = el.parentElement; + while (node && node !== doc.documentElement) { + const nr = node.getBoundingClientRect(); + const containsElement = + nr.width > 0 && nr.height > 0 && + nr.left <= er.left + 0.5 && + nr.top <= er.top + 0.5 && + nr.right >= er.right - 0.5 && + nr.bottom >= er.bottom - 0.5; + if (containsElement && paintsShaderProxySurface(node)) return node; + node = node.parentElement; + } + return null; + } + // Capture the element (with current annotations baked in) and return // { blob, paper }: the PNG Blob, plus the representative backdrop tone for the // shader's halftone ground (so capture, upload, and shader all agree on what // sits behind the element). Shared between the Go flow (uploads the blob) and // the shader-resume path. + async function captureElementFromRenderedAncestor(ms, el, opts) { + const doc = el.ownerDocument || document; + const captureRoot = findShaderProxyCaptureRoot(el); + if (!captureRoot) throw new Error('No painted ancestor for Svelte shader proxy'); + const rootCanvas = await ms.domToCanvas(captureRoot, opts); + const S = opts.scale; + const er = el.getBoundingClientRect(); + const rr = captureRoot.getBoundingClientRect(); + const sx = (er.left - rr.left) * S; + const sy = (er.top - rr.top) * S; + const sw = er.width * S; + const sh = er.height * S; + if (sw <= 0 || sh <= 0) throw new Error('Selected element has no visible capture rect'); + const crop = doc.createElement('canvas'); + crop.width = Math.max(1, Math.round(sw)); + crop.height = Math.max(1, Math.round(sh)); + const cctx = crop.getContext('2d', { willReadFrequently: true }); + cctx.drawImage(rootCanvas, sx, sy, sw, sh, 0, 0, crop.width, crop.height); + const paper = dominantRgb01(cctx, crop.width, crop.height) || averageRgb01(cctx, crop.width, crop.height); + const blob = await new Promise((res) => crop.toBlob(res, 'image/png')); + if (!blob) throw new Error('Ancestor crop failed to produce a PNG blob'); + return { blob, paper }; + } + async function captureElementToBlob(el, snapshot, rect) { try { if (document.fonts?.ready) await document.fonts.ready; } catch {} const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0); @@ -5261,6 +6297,13 @@ scale: Math.min(window.devicePixelRatio || 1, 2), font: fontCssText ? { cssText: fontCssText } : undefined, }; + if (shouldUseAncestorCropShaderProxy(el)) { + try { + return await hideCaptureChromeForShaderProxy(() => captureElementFromRenderedAncestor(ms, el, opts)); + } catch (err) { + console.warn('[impeccable] Svelte ancestor crop capture failed, falling back to element capture:', err); + } + } const bg = resolveCanvasBackground(el); // Fast path: the element paints its own background, or an opaque ancestor // color was found. modern-screenshot bakes that color; paper matches it. @@ -5342,13 +6385,13 @@ sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload); } - // --------------------------------------------------------------------------- + // // Shader overlay - renders the captured screenshot as a WebGL texture and // runs an editorial "ink-wash" fragment shader over it during generation. // A single rolling band sweeps top-to-bottom, desaturating + tinting kinpaku // and leaving a soft trail. Makes the wait feel like a letterpress scan // instead of a dead spinner. - // --------------------------------------------------------------------------- + // const SHADER_VS = `attribute vec2 a_position; attribute vec2 a_uv; @@ -5502,6 +6545,31 @@ void main() { return n ? [r / n / 255, g / n / 255, b / n / 255] : SHADER_PAPER_FALLBACK; } + // Pick the most common visible color cluster from a crop. A straight average + // gets pulled by text and icons; the dominant bucket usually represents the + // surface the shader should dissolve into. + function dominantRgb01(ctx, w, h) { + const data = ctx.getImageData(0, 0, w, h).data; + const stride = Math.max(1, Math.floor((w * h) / 6000)); + const buckets = new Map(); + for (let p = 0; p < w * h; p += stride) { + const i = p * 4; + if (data[i + 3] < 16) continue; + const key = (data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4); + const bucket = buckets.get(key) || { count: 0, r: 0, g: 0, b: 0 }; + bucket.count += 1; + bucket.r += data[i]; + bucket.g += data[i + 1]; + bucket.b += data[i + 2]; + buckets.set(key, bucket); + } + let best = null; + for (const bucket of buckets.values()) { + if (!best || bucket.count > best.count) best = bucket; + } + return best ? [best.r / best.count / 255, best.g / best.count / 255, best.b / best.count / 255] : null; + } + // Average the backdrop sampled just OUTSIDE an element's rect within a larger // canvas. The ground tone for the dissolve must be the real backdrop, not the // mean of the element's own crop - averaging the crop folds in the element's @@ -5572,7 +6640,7 @@ void main() { fallback.style.backgroundRepeat = 'no-repeat'; fallback.style.outline = '2px dashed ' + C.brand; fallback.style.outlineOffset = '-2px'; - document.body.appendChild(fallback); + uiAppend(fallback); shaderState = { canvas: fallback, gl: null, program: null, texture: null, rafId: 0, startTime: 0, objectUrl }; } @@ -5582,16 +6650,19 @@ void main() { const canvas = document.createElement('canvas'); canvas.id = PREFIX + '-shader'; const dpr = Math.min(window.devicePixelRatio || 1, 2); + const radius = getComputedStyle(el).borderRadius; canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); Object.assign(canvas.style, { position: 'fixed', top: rect.top + 'px', left: rect.left + 'px', width: rect.width + 'px', height: rect.height + 'px', + borderRadius: radius, + overflow: 'hidden', pointerEvents: 'none', zIndex: Z.bar - 1, }); - document.body.appendChild(canvas); + uiAppend(canvas); const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false }) || canvas.getContext('experimental-webgl'); @@ -5685,8 +6756,12 @@ void main() { frame(); } - function handleAccept() { + async function handleAccept() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } + if (pendingAcceptedSession || state === 'SAVING') return; + if (variantSelectionPromise) { + try { await variantSelectionPromise; } catch { /* failed selection falls back below */ } + } if (!currentSessionId || arrivedVariants === 0) return; const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId); if (domVisibleVariant > 0) visibleVariant = domVisibleVariant; @@ -5696,30 +6771,39 @@ void main() { variantId: String(visibleVariant), pageUrl: location.pathname, }; + const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); if (Object.keys(paramsCurrentValues).length > 0) { acceptPayload.paramValues = { ...paramsCurrentValues }; } + // The accepted variant is already the only visible child of the wrapper + // (all other variants are display:none). HMR from the source rewrite will + // replace the wrapper imminently. Don't eagerly replaceChild here - React + // reconciliation races with our mutation and throws NotFoundError in Next + // 16 / Turbopack. Schedule a fallback that runs the manual swap only if + // HMR hasn't cleaned up by then (keeps static-server flows working). const acceptedSessionId = currentSessionId; const acceptedVariant = visibleVariant; + const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId + || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component'; const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant); - pendingAcceptedSession = { - id: acceptedSessionId, - variant: String(acceptedVariant), - ...acceptedSnapshot, - finalizing: false, - }; state = 'SAVING'; updateBarContent('saving'); + pendingAcceptedSession = { + id: acceptedSessionId, + variant: String(acceptedVariant), + isSvelteComponent: acceptedIsSvelteComponent, + ...acceptedSnapshot, + finalizing: false, + }; + saveSession(); sendEvent(acceptPayload, { throwOnError: true }) - .then(() => { - markSessionHandled(); - }) + .then(() => {}) .catch(() => { - pendingAcceptedSession = null; + if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; state = 'CYCLING'; - updateBarContent('cycling'); + showOrUpdateCyclingBar(); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); }); } @@ -5733,19 +6817,21 @@ void main() { } if (pending.finalizing) return true; pending.finalizing = true; - + markSessionHandled(); + if (pending.isSvelteComponent) { + commitAcceptedSvelteComponentToDom(pending.id); + } state = 'CONFIRMED'; updateBarContent('confirmed'); + scheduleAcceptCleanup(pending); + return true; + } - // Give framework HMR a short chance to render the now-clean accepted - // source. If it misses the update, unwrap the accepted variant after the - // source-side completion event so the page is not left empty or stale. + function scheduleAcceptCleanup(accepted) { setTimeout(function() { - ensureAcceptedDomClean(pending); + if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted); cleanupAcceptedSession(); }, 1200); - - return true; } function snapshotAcceptedVariantDom(sessionId, variantId) { @@ -5833,6 +6919,7 @@ void main() { stopScrollLock(); clearScrollY(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5841,6 +6928,28 @@ void main() { state = 'PICKING'; } + function commitAcceptedVariantToDom(sessionId, variantId) { + const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); + if (!wrapper) return false; + const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); + if (!accepted || !accepted.firstElementChild) return false; + const parent = wrapper.parentElement; + if (!parent) return false; + + const style = wrapper.querySelector('style[data-impeccable-css]'); + if (style && !document.querySelector('style[data-impeccable-accepted-css="' + sessionId + '"]')) { + const promotedStyle = style.cloneNode(true); + promotedStyle.setAttribute('data-impeccable-accepted-css', sessionId); + parent.insertBefore(promotedStyle, wrapper); + } + + const committed = accepted.cloneNode(true); + committed.removeAttribute('hidden'); + committed.style.display = 'contents'; + parent.replaceChild(committed, wrapper); + return true; + } + function handleDiscard() { if (pendingApplyInFlight) { showManualApplyBusyToast(); return; } if (!currentSessionId) return; @@ -5852,11 +6961,141 @@ void main() { .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000)); } - // --------------------------------------------------------------------------- + // // Session persistence via live-browser-session.js - // --------------------------------------------------------------------------- + // // Survives page reloads, browser close/reopen, HMR, and accidental refreshes. + function normalizeSessionPath(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed.replace(/\\/g, '/') : null; + } + + function resetSessionFileMeta() { + currentSourceFile = null; + currentPreviewFile = null; + currentPreviewMode = null; + recoveryWaitingForAnchor = false; + } + + function rememberSessionFileMeta(meta = {}) { + const file = normalizeSessionPath(meta.file); + const sourceFile = normalizeSessionPath(meta.sourceFile); + const previewFile = normalizeSessionPath(meta.previewFile); + const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null); + + if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) { + currentPreviewMode = 'svelte-component'; + currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile); + currentSourceFile = sourceFile || currentSourceFile; + return; + } + + if (sourceFile || file) currentSourceFile = sourceFile || file; + if (previewFile) currentPreviewFile = previewFile; + if (previewMode) currentPreviewMode = previewMode; + } + + function applySavedSessionMeta(saved) { + if (!saved) return; + rememberSessionFileMeta(saved); + if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder; + if (saved.action) selectedAction = saved.action; + if (saved.count) selectedCount = saved.count; + if (saved.previewMode) currentPreviewMode = saved.previewMode; + if (saved.paramValues && typeof saved.paramValues === 'object') { + paramsCurrentValues = { ...saved.paramValues }; + } + } + + function normalizePagePath(value) { + if (!value || typeof value !== 'string') return null; + try { + return new URL(value, location.origin).pathname; + } catch { + return value.split(/[?#]/)[0] || null; + } + } + + function pageMatchesCurrent(value) { + const path = normalizePagePath(value); + return !path || path === location.pathname; + } + + function isTerminalSessionSummary(session) { + return /^(completed|discarded|discard_requested|accept_requested)$/.test(String(session?.phase || '')); + } + + function findActiveSessionSummary(saved, activeSessions) { + if (!saved?.id || !Array.isArray(activeSessions)) return null; + return activeSessions.find((session) => + session?.id === saved.id + && pageMatchesCurrent(session.pageUrl || saved.pageUrl) + && !isTerminalSessionSummary(session) + ) || null; + } + + function clampVariantIndex(value, count) { + const num = Number(value); + const max = Number(count); + if (!Number.isFinite(num) || num < 1) return 0; + if (Number.isFinite(max) && max > 0 && num > max) return 0; + return Math.floor(num); + } + + function restoreSessionWithoutWrapper(reason, activeSessions) { + const saved = loadSession(); + if (!saved?.id || isSessionHandled(saved.id)) return false; + const savedState = String(saved.state || '').toUpperCase(); + if (savedState !== 'GENERATING' && savedState !== 'CYCLING') return false; + + const serverSession = findActiveSessionSummary(saved, activeSessions); + if (Array.isArray(activeSessions) && activeSessions.length > 0 && !serverSession) { + return false; + } + + currentSessionId = saved.id; + applySavedSessionMeta(serverSession); + applySavedSessionMeta(saved); + + expectedVariants = Number(saved.expected || serverSession?.expectedVariants || selectedCount || 0); + arrivedVariants = Number(saved.arrived || serverSession?.arrivedVariants || 0); + if (arrivedVariants <= 0 && currentPreviewFile) arrivedVariants = Number(serverSession?.expectedVariants || saved.expected || selectedCount || 0); + if (expectedVariants <= 0) expectedVariants = Number(serverSession?.expectedVariants || arrivedVariants || selectedCount || 0); + visibleVariant = clampVariantIndex(saved.visible, arrivedVariants || expectedVariants) + || clampVariantIndex(serverSession?.visibleVariant, arrivedVariants || expectedVariants) + || (arrivedVariants > 0 ? 1 : 0); + + selectedElement = document.body; + state = 'GENERATING'; + recoveryWaitingForAnchor = true; + showBar('generating'); + startScrollTracking(); + if (variantObserver) variantObserver.disconnect(); + variantObserver = startVariantObserver(currentSessionId); + saveSession(); + queueCheckpoint(reason || 'browser_restore_without_wrapper'); + + const restoreFile = currentPreviewMode === 'svelte-component' + ? currentPreviewFile + : (currentSourceFile || currentPreviewFile); + if (restoreFile) { + injectVariantsFromSource(restoreFile, currentSessionId); + return true; + } + + showToast('Variants ready. Reveal the selected element to resume.', 15000); + return true; + } + + function restoreFromActiveSessions(activeSessions, reason) { + const wrapper = document.querySelector('[data-impeccable-variants]'); + if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false; + if (svelteComponentSession?.sessionId === currentSessionId) return false; + return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); + } + function saveSession() { if (!currentSessionId) return; // NOTE: scrollY is stored under a separate key (writeScrollY). Storing @@ -5869,6 +7108,11 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, + sourceFile: currentSourceFile || undefined, + previewFile: currentPreviewFile || undefined, + previewMode: currentPreviewMode || undefined, + pageUrl: location.pathname, + paramValues: { ...paramsCurrentValues }, insertPlaceholder: insertPlaceholderSnapshot || undefined, }); } @@ -5898,31 +7142,33 @@ void main() { } function cleanup() { - // Hide the wrapper immediately so variants disappear. DON'T structurally - // mutate the DOM yet - HMR from the agent's source rewrite is on its way, - // and a manual replaceChild under React causes NotFoundError when the - // reconciler later tries to remove a wrapper we already removed. - // Schedule a 2s fallback that does the manual swap only if HMR hasn't - // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const cleanupSessionId = currentSessionId; - if (cleanupSessionId) { + if (svelteComponentSession?.sessionId === cleanupSessionId) { + teardownSvelteComponentSession(true); + } else if (cleanupSessionId) { + // Hide the wrapper immediately so variants disappear. DON'T structurally + // mutate the DOM yet - HMR from the agent's source rewrite is on its way, + // and a manual replaceChild under React causes NotFoundError when the + // reconciler later tries to remove a wrapper we already removed. + // Schedule a 2s fallback that does the manual swap only if HMR hasn't + // replaced the wrapper by then (keeps static-server / no-HMR flows alive). const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); if (wrapper) wrapper.style.display = 'none'; - } - setTimeout(function() { - if (!cleanupSessionId) return; - const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); - if (!wrapper) return; - const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); - if (orig) { - const content = orig.firstElementChild; - if (content) { - wrapper.parentElement.replaceChild(content, wrapper); - return; + setTimeout(function() { + if (!cleanupSessionId) return; + const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]'); + if (!lateWrapper) return; + const orig = lateWrapper.querySelector('[data-impeccable-variant="original"]'); + if (orig) { + const content = orig.firstElementChild; + if (content) { + lateWrapper.parentElement.replaceChild(content, lateWrapper); + return; + } } - } - wrapper.remove(); - }, 2000); + lateWrapper.remove(); + }, 2000); + } hideBar(); hideHighlight(); stopScrollTracking(); @@ -5931,6 +7177,7 @@ void main() { clearScrollY(); finalizeInsertSession(); clearSession(); + resetSessionFileMeta(); selectedElement = null; currentSessionId = null; selectedAction = 'impeccable'; @@ -5938,9 +7185,9 @@ void main() { state = 'PICKING'; } - // --------------------------------------------------------------------------- + // // Toast - // --------------------------------------------------------------------------- + // function showToast(message, duration) { if (toastEl) toastEl.remove(); @@ -5964,7 +7211,7 @@ void main() { }); toastEl.id = PREFIX + '-toast'; toastEl.textContent = message; - document.body.appendChild(toastEl); + uiAppend(toastEl); requestAnimationFrame(() => { toastEl.style.opacity = '1'; toastEl.style.transform = 'translateX(-50%) translateY(0)'; @@ -5978,22 +7225,70 @@ void main() { }, duration); } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // // Resume an active variant session after HMR/page reload. // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote // variants before HMR fired. Pick up where we left off. function resumeSession() { const wrapper = document.querySelector('[data-impeccable-variants]'); - if (!wrapper) { clearSession(); clearHandled(); return false; } + if (!wrapper) { + if (restoreSessionWithoutWrapper('browser_resumed_without_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } const sessionId = wrapper.dataset.impeccableVariants; // Don't resume if this session was already accepted/discarded if (isSessionHandled(sessionId)) return false; + // Svelte component sessions can't be resumed by counting DOM children: the + // wrapper holds a single mount target, not [data-impeccable-variant] nodes, + // and a page reload unmounts every compiled variant. Counting children here + // would strand the bar in CYCLING at 0/0. If there's no live in-memory mount + // for this wrapper, it's an orphan (reload / failed mount): drop it and let + // the live-server's SSE re-inject the manifest if the session is still live. + if (wrapper.dataset.impeccablePreview === 'svelte-component' + && svelteComponentSession?.sessionId !== sessionId) { + wrapper.remove(); + if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true; + clearSession(); + clearHandled(); + return false; + } + + if (wrapper.dataset.impeccablePreview === 'svelte-component') { + if (!svelteComponentSession?.mountedVariant) { + return true; + } + currentSessionId = sessionId; + expectedVariants = Number(wrapper.dataset.impeccableVariantCount) + || Number(svelteComponentSession.manifest?.count) + || expectedVariants + || 1; + arrivedVariants = expectedVariants; + const saved = loadSession(); + applySavedSessionMeta(saved); + const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0; + visibleVariant = svelteComponentSession.mountedVariant > 0 && svelteComponentSession.mountedVariant <= arrivedVariants + ? svelteComponentSession.mountedVariant + : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1); + selectedElement = resolveSvelteComponentAnchor() + || wrapper.parentElement; + state = 'CYCLING'; + hideShaderOverlay(); + showBar('cycling'); + startScrollTracking(); + refreshParamsPanel(); + saveSession(); + queueCheckpoint('browser_resumed_svelte_component'); + return true; + } + currentSessionId = sessionId; expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); @@ -6002,6 +7297,7 @@ void main() { // Restore state from localStorage if available const saved = loadSession(); if (saved && saved.id === sessionId) { + applySavedSessionMeta(saved); visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0); if (saved.action) selectedAction = saved.action; if (saved.count) selectedCount = saved.count; @@ -6072,9 +7368,9 @@ void main() { return true; } - // --------------------------------------------------------------------------- + // // Global bar (always visible at bottom) - // --------------------------------------------------------------------------- + // let globalBarEl = null; let globalBarBrandEl = null; @@ -6166,6 +7462,8 @@ void main() { let pageChatExpanded = false; let steerLocked = false; let steerRequestId = null; + let steerPendingMessage = ''; + let steerInputWasFocused = false; let pageChatDotsEl = null; let steerAwaitTimer = null; let voiceRecognition = null; @@ -6179,7 +7477,7 @@ void main() { const STEER_AWAIT_TIMEOUT_MS = 120000; const AGENT_STATUS_POLL_MS = 5000; const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)'; - const AGENT_DISCONNECTED_TIP = 'Agent disconnected: run live-poll.mjs to connect'; + const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect'; const GLOBAL_BAR_SECTION_GAP = 8; const GLOBAL_BAR_INNER_GAP = 2; const GLOBAL_BAR_INNER_PAD_LEFT = 2; @@ -6323,7 +7621,7 @@ void main() { const attempt = () => { steerFocusRecoverTimer = null; if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (pageHasHostTextSelection()) { steerFocusRecoverTimer = setTimeout(attempt, 120); return; @@ -6344,7 +7642,7 @@ void main() { steerFocusSuspended = true; steerFocusPauseUntil = performance.now() + STEER_PAGE_FOCUS_PAUSE_MS; pagePointerGesture = { x: e.clientX, y: e.clientY, dragged: false }; - if (pageChatInput && document.activeElement === pageChatInput) { + if (pageChatInput && activeElementDeep() === pageChatInput) { pageChatInput.blur(); } } @@ -6404,7 +7702,7 @@ void main() { pickActive, pageChatReady: !!pageChatInput, pageChatExpanded, - active: steerFocusTargetLabel(document.activeElement), + active: steerFocusTargetLabel(activeElementDeep()), shouldSteer: shouldFocusSteerChat(), ...(extra || {}), }); @@ -6423,26 +7721,26 @@ void main() { function focusConfigureInput(reason) { steerFocusLog('focusConfigureInput', { reason }); const inputId = configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input'; - const input = document.getElementById(inputId); + const input = uiGetById(inputId); if (!input) { steerFocusLog('focusConfigureInput missing', { reason }); return; } setTimeout(() => { - const before = document.activeElement; + const before = activeElementDeep(); input.focus(); steerFocusLog('focusConfigureInput result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== input, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== input, }); }, 60); } function syncPageChatFocusRing() { if (!pageChatEl || !pageChatInput) return; - const focused = document.activeElement === pageChatInput; + const focused = activeElementDeep() === pageChatInput; pageChatEl.dataset.inputFocused = focused ? 'true' : 'false'; const P = pageChatPalette(); pageChatEl.style.borderColor = steerLocked @@ -6476,15 +7774,15 @@ void main() { } syncPageChatVisual(); pageChatInput.style.pointerEvents = 'auto'; - const before = document.activeElement; + const before = activeElementDeep(); try { window.focus(); } catch { /* embed may block */ } try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } syncPageChatFocusRing(); steerFocusLog('focusSteerChat result', { reason, before: steerFocusTargetLabel(before), - after: steerFocusTargetLabel(document.activeElement), - stuck: document.activeElement !== pageChatInput, + after: steerFocusTargetLabel(activeElementDeep()), + stuck: activeElementDeep() !== pageChatInput, }); } @@ -6515,6 +7813,37 @@ void main() { return wrap; } + function keepSteerPointerInside(e, opts = {}) { + e.stopPropagation(); + if (opts.preventDefault !== false) e.preventDefault(); + } + + function preparePageChatInputForTyping() { + if (!pageChatEl || !pageChatInput) return false; + pageChatExpanded = true; + pageChatEl.dataset.expanded = 'true'; + pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; + pageChatEl.style.cursor = steerLocked ? 'default' : 'text'; + if (pageChatHint) { + pageChatHint.style.display = 'none'; + pageChatHint.style.opacity = '0'; + } + pageChatInput.style.width = ''; + pageChatInput.style.padding = '0 6px'; + pageChatInput.style.opacity = steerLocked ? '0.72' : '1'; + pageChatInput.style.pointerEvents = steerLocked ? 'none' : 'auto'; + return true; + } + + function focusPageChatInput(reason) { + if (!preparePageChatInputForTyping() || steerLocked) return false; + try { pageChatInput.focus({ preventScroll: true }); } catch { pageChatInput.focus(); } + const focused = activeElementDeep() === pageChatInput; + if (focused) steerInputWasFocused = true; + syncPageChatFocusRing(); + return focused; + } + function clearSteerAwaitTimer() { if (steerAwaitTimer) { clearTimeout(steerAwaitTimer); @@ -6528,6 +7857,7 @@ void main() { if (!steerLocked || steerRequestId !== id) return; unlockSteerChat({ error: 'Steer timed out waiting for the agent. Check that live-poll is running and replies with steer_done.', + restoreMessage: steerPendingMessage, }); }, STEER_AWAIT_TIMEOUT_MS); } @@ -6538,19 +7868,12 @@ void main() { steerLocked = true; pageChatEl.dataset.processing = 'true'; pageChatInput.disabled = true; - pageChatInput.value = ''; - pageChatInput.blur(); + preparePageChatInputForTyping(); if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = true; pageChatVoiceBtn.style.display = 'none'; } - pageChatExpanded = false; - pageChatEl.dataset.expanded = 'false'; - pageChatEl.style.width = PAGE_CHAT_PROCESSING_W; pageChatEl.style.cursor = 'default'; - pageChatInput.style.width = '0'; - pageChatInput.style.padding = '0'; - pageChatInput.style.opacity = '0'; pageChatInput.style.pointerEvents = 'none'; if (pageChatHint) { pageChatHint.style.display = 'none'; @@ -6568,17 +7891,26 @@ void main() { function unlockSteerChat(opts) { clearSteerAwaitTimer(); + const restoreMessage = typeof opts?.restoreMessage === 'string' ? opts.restoreMessage : ''; + const keepExpanded = Boolean(opts?.error && restoreMessage); steerLocked = false; + const completedId = steerRequestId; steerRequestId = null; if (!pageChatEl) return; pageChatEl.dataset.processing = 'false'; pageChatEl.removeAttribute('aria-busy'); pageChatEl.setAttribute('aria-label', 'Steer the page'); - pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W; + pageChatExpanded = keepExpanded; + pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false'; + pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W; pageChatEl.style.cursor = 'pointer'; if (pageChatInput) { pageChatInput.disabled = false; - pageChatInput.value = ''; + pageChatInput.value = keepExpanded ? restoreMessage : ''; + pageChatInput.style.width = keepExpanded ? '' : '0'; + pageChatInput.style.padding = keepExpanded ? '0 6px' : '0'; + pageChatInput.style.opacity = keepExpanded ? '1' : '0'; + pageChatInput.style.pointerEvents = 'auto'; } if (pageChatVoiceBtn) { pageChatVoiceBtn.disabled = false; @@ -6586,18 +7918,28 @@ void main() { } if (pageChatHint) { pageChatHint.textContent = 'Steer'; - pageChatHint.style.display = ''; - pageChatHint.style.visibility = ''; + pageChatHint.style.display = keepExpanded ? 'none' : ''; + pageChatHint.style.visibility = keepExpanded ? 'hidden' : ''; + pageChatHint.style.opacity = keepExpanded ? '0' : '1'; } if (pageChatDotsEl?.parentNode) { pageChatDotsEl.remove(); pageChatDotsEl = null; } + steerPendingMessage = keepExpanded ? restoreMessage : ''; + steerInputWasFocused = false; syncPageChatChrome(); syncPageChatFocusRing(); if (opts?.error) showToast(String(opts.error), 5000); else if (opts?.message) showToast(String(opts.message), 4000); - syncPageChatFocus('steer-unlock'); + if (completedId) { + sendSteerCheckpoint(completedId, opts?.error ? 'steer_error' : 'steer_done', { + message: opts?.message || opts?.error || '', + file: opts?.file || '', + }); + } + if (keepExpanded) focusPageChatInput('steer-error-restore'); + else syncPageChatFocus('steer-unlock'); } function steerSpeechRecognitionCtor() { @@ -6651,7 +7993,7 @@ void main() { if (pageChatEl) pageChatEl.dataset.voiceListening = listening ? 'true' : 'false'; syncPageChatChrome(); } else if (voiceCtx?.mode === 'configure') { - const voiceBtn = document.getElementById(PREFIX + '-configure-voice'); + const voiceBtn = uiGetById(PREFIX + '-configure-voice'); if (voiceBtn) { voiceBtn.dataset.active = listening ? 'true' : 'false'; voiceBtn.dataset.listening = listening ? 'true' : 'false'; @@ -6784,7 +8126,7 @@ void main() { } function configureVoiceContext() { - const input = document.getElementById( + const input = uiGetById( configureKind === 'insert' ? PREFIX + '-insert-input' : PREFIX + '-input', ); return { @@ -6819,26 +8161,37 @@ void main() { if (!text || steerLocked) return; const id = id8(); steerRequestId = id; + steerPendingMessage = text; + if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); lockSteerChat(); scheduleSteerAwaitTimeout(id); + sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); sendEvent({ type: 'steer', id, message: text, pageUrl: location.href, }).then((res) => { - if (!res) unlockSteerChat({ error: 'Could not reach live server' }); + if (!res) { + sendSteerCheckpoint(id, 'steer_send_failed', { message: text }); + unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text }); + } }); } function maybeCompleteSteer(msg) { if (!steerRequestId || msg.id !== steerRequestId) return false; if (msg.type === 'steer_done') { - unlockSteerChat({ message: msg.message }); + unlockSteerChat({ message: msg.message, file: msg.file }); + if (msg.file && /\.svelte(?:$|\?)/.test(String(msg.file))) { + setTimeout(() => { + if (!steerLocked) showToast('Steer applied. Reload if the page has not refreshed yet.', 5000); + }, 4500); + } return true; } if (msg.type === 'error') { - unlockSteerChat({ error: msg.message || 'Steer failed' }); + unlockSteerChat({ error: msg.message || 'Steer failed', restoreMessage: steerPendingMessage }); return true; } return false; @@ -6847,21 +8200,10 @@ void main() { function expandPageChat(opts) { const focus = !opts || opts.focus !== false; if (!pageChatEl || !pageChatInput || steerLocked) return; - pageChatExpanded = true; - pageChatEl.dataset.expanded = 'true'; - pageChatEl.style.width = PAGE_CHAT_EXPANDED_W; - pageChatEl.style.cursor = 'text'; - if (pageChatHint) { - pageChatHint.style.display = 'none'; - pageChatHint.style.opacity = '0'; - } - pageChatInput.style.width = ''; - pageChatInput.style.padding = '0 6px'; - pageChatInput.style.opacity = '1'; - pageChatInput.style.pointerEvents = 'auto'; + preparePageChatInputForTyping(); syncPageChatChrome(); syncPageChatFocusRing(); - if (focus) pageChatInput.focus(); + if (focus) focusPageChatInput('expand-page-chat'); } function collapsePageChat(opts) { @@ -6878,7 +8220,7 @@ void main() { } else { pageChatInput.style.pointerEvents = 'auto'; } - if (pageChatHint && document.activeElement !== pageChatInput) { + if (pageChatHint && activeElementDeep() !== pageChatInput) { pageChatHint.style.display = ''; pageChatHint.style.opacity = '1'; } @@ -6952,7 +8294,7 @@ void main() { pageChatEl.appendChild(pageChatInput); pageChatEl.appendChild(pageChatVoiceBtn); - if (!document.getElementById(PREFIX + '-page-chat-style')) { + if (!uiGetById(PREFIX + '-page-chat-style')) { const s = document.createElement('style'); s.id = PREFIX + '-page-chat-style'; s.textContent = @@ -6966,23 +8308,34 @@ void main() { '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' + '#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' + '#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }'; - document.head.appendChild(s); + uiAppendStyle(s); } - pageChatEl.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatEl.addEventListener('pointerdown', keepSteerPointerInside); + pageChatEl.addEventListener('mousedown', keepSteerPointerInside); pageChatEl.addEventListener('click', (e) => { + keepSteerPointerInside(e); if (steerLocked) return; if (pageChatVoiceBtn.contains(e.target)) return; - expandPageChat(); + expandPageChat({ focus: false }); + focusPageChatInput('page-chat-click'); }); - pageChatVoiceBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + pageChatVoiceBtn.addEventListener('pointerdown', keepSteerPointerInside); + pageChatVoiceBtn.addEventListener('mousedown', keepSteerPointerInside); pageChatVoiceBtn.addEventListener('click', (e) => { - e.stopPropagation(); + keepSteerPointerInside(e); if (steerLocked) return; toggleSteerVoice(); }); + pageChatInput.addEventListener('pointerdown', keepSteerPointerInside); + pageChatInput.addEventListener('mousedown', keepSteerPointerInside); + pageChatInput.addEventListener('click', (e) => { + keepSteerPointerInside(e); + if (!steerLocked) focusPageChatInput('page-chat-input-click'); + }); + pageChatInput.addEventListener('input', () => { syncPageChatVisual(); }); @@ -6995,7 +8348,7 @@ void main() { syncPageChatFocusRing(); setTimeout(() => { if (state === 'CONFIGURING' || steerLocked || voiceListening) return; - if (pageChatEl?.contains(document.activeElement)) return; + if (pageChatEl?.contains(activeElementDeep())) return; if (!pageChatInput.value.trim()) collapsePageChat(); scheduleSteerFocusRecover('steer-blur-recover'); }, 120); @@ -7039,7 +8392,7 @@ void main() { globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false'; globalBarBrandEl.setAttribute('aria-label', connected ? 'Impeccable live mode' - : 'Impeccable live mode: agent not polling'); + : 'Impeccable live mode - agent not polling'); globalBarBrandEl.removeAttribute('title'); globalBarBrandEl.style.cursor = connected ? 'default' : 'help'; const mark = globalBarBrandEl.querySelector('[data-brand-mark]'); @@ -7077,7 +8430,7 @@ void main() { }); agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip'; agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP; - document.body.appendChild(agentPollTooltipEl); + uiAppend(agentPollTooltipEl); return agentPollTooltipEl; } @@ -7131,7 +8484,7 @@ void main() { // Custom focus-visible for bar buttons. Browser default is a heavy // blue ring that looks jarring on the dark capsule. Replace with a // soft accent-tinted inner ring that respects the bar's palette. - if (!document.getElementById(PREFIX + '-bar-focus-style')) { + if (!uiGetById(PREFIX + '-bar-focus-style')) { const s = document.createElement('style'); s.id = PREFIX + '-bar-focus-style'; s.textContent = @@ -7143,7 +8496,7 @@ void main() { '@keyframes impeccable-agent-dot { 0%, 100% { opacity: 0.45; transform: scale(0.9); } 50% { opacity: 1; transform: scale(1); } }' + '#' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: impeccable-agent-dot 1.4s ease-in-out infinite; }' + '@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-global-bar-brand[data-agent-connected="false"] [data-agent-dot] { animation: none; opacity: 0.9; } }'; - document.head.appendChild(s); + uiAppendStyle(s); } globalBarEl = el('div', { @@ -7176,7 +8529,7 @@ void main() { brand.id = PREFIX + '-global-bar-brand'; brand.dataset.agentConnected = 'false'; brand.setAttribute('role', 'img'); - brand.setAttribute('aria-label', 'Impeccable live mode: agent not polling'); + brand.setAttribute('aria-label', 'Impeccable live mode - agent not polling'); const brandMark = el('span', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -7211,7 +8564,7 @@ void main() { inner.id = PREFIX + '-global-bar-inner'; globalBarEl.appendChild(inner); - // --- button factory: icon-only at rest, label slides in on hover/active --- + // Button factory: icon-only at rest, label slides in on hover/active. function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { const b = el('button', { position: 'relative', @@ -7506,6 +8859,7 @@ void main() { color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', }); + exitBtn.id = PREFIX + '-exit'; exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = 'oklch(58% 0.15 35)'; exitBtn.style.background = P.exitHover; }); @@ -7530,8 +8884,8 @@ void main() { try { window.focus(); } catch { /* in-app preview may block */ } }, true); - document.body.appendChild(pendingDockEl); - document.body.appendChild(globalBarEl); + uiAppend(pendingDockEl); + uiAppend(globalBarEl); defangOutsideHandlers(pendingDockEl); defangOutsideHandlers(globalBarEl); @@ -7553,11 +8907,11 @@ void main() { } function updateGlobalBarState() { - const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); - const detectBadge = document.getElementById(PREFIX + '-detect-badge'); - const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); - const insertToggle = document.getElementById(PREFIX + '-insert-toggle'); - const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const detectToggle = uiGetById(PREFIX + '-detect-toggle'); + const detectBadge = uiGetById(PREFIX + '-detect-badge'); + const pickToggle = uiGetById(PREFIX + '-pick-toggle'); + const insertToggle = uiGetById(PREFIX + '-insert-toggle'); + const designToggle = uiGetById(PREFIX + '-design-toggle'); const theme = globalBarEl?.dataset.theme || 'light'; const P = barPaletteForTheme(theme); @@ -7751,8 +9105,9 @@ void main() { pendingApplyInFlight = false; } if (globalBarEl) { - globalBarEl.style.transform = 'translateY(100%)'; - setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300); + globalBarEl.style.transition = 'none'; + globalBarEl.remove(); + globalBarEl = null; } pageChatEl = null; pageChatInput = null; @@ -7765,6 +9120,7 @@ void main() { if (barEl) { barEl.remove(); barEl = null; } if (pickerEl) { pickerEl.remove(); pickerEl = null; } if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; } + if (editBadgeProxyRoot) { editBadgeProxyRoot.remove(); editBadgeProxyRoot = null; editBadgeProxyByTarget = new Map(); } if (evtSource) { evtSource.close(); evtSource = null; } document.removeEventListener('mousemove', handleMouseMove, true); document.removeEventListener('click', handleClick, true); @@ -7777,9 +9133,9 @@ void main() { console.log('[impeccable] Live mode exited.'); } - // --------------------------------------------------------------------------- + // // Design System Panel - visualizes the project's .impeccable/design.json sidecar - // --------------------------------------------------------------------------- + // const DESIGN_PREFS_KEY = 'impeccable-live-design-panel'; const DESIGN_PANEL_WIDTH = 440; @@ -7847,7 +9203,7 @@ void main() { root.className = 'root'; designShadow.appendChild(root); - document.body.appendChild(designHost); + uiAppend(designHost); // The host is pointer-events: none; the panel inside the shadow DOM // manages its own auto/none. Events bubble through the shadow boundary, // so attaching here silences host-page outside-interaction handlers @@ -7889,7 +9245,7 @@ void main() { .root * { box-sizing: border-box; } button { font: inherit; color: inherit; } - /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + /* Panel shell: chrome matches the bar; body canvas stays neutral */ .panel { position: fixed; top: 12px; bottom: 72px; right: 12px; width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); @@ -7955,7 +9311,7 @@ void main() { .panel-body::-webkit-scrollbar { width: 8px; } .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } - /* --- States --- */ + /* States */ .empty, .loading, .error { margin: 16px 4px; padding: 28px 20px; text-align: center; @@ -7966,7 +9322,7 @@ void main() { .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; } .error { color: oklch(45% 0.15 25); } - /* --- Stale hint --- */ + /* Stale hint */ .stale { display: flex; align-items: center; gap: 8px; margin: 8px 4px 12px; @@ -7979,7 +9335,7 @@ void main() { .stale-text { flex: 1; min-width: 0; } .stale-text strong { color: ${DP.ink}; font-weight: 600; } - /* --- Parsed-md fallback banner --- */ + /* Parsed-md fallback banner */ .parsed-md-cta { margin: 8px 4px 14px; padding: 14px 16px; @@ -7991,7 +9347,7 @@ void main() { .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; } .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; } - /* --- Tile primitives --- */ + /* Tile primitives */ .tile { position: relative; background: ${DP.tile}; @@ -8010,7 +9366,7 @@ void main() { } .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; } - /* --- Color tile --- */ + /* Color tile */ .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; } .c-tile:hover { transform: translateY(-1px); } .c-hero { @@ -8025,7 +9381,7 @@ void main() { .c-ramp > span { flex: 1; } .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Type tile --- */ + /* Type tile */ .t-tile { } .t-specimen { margin: 4px 0 6px; @@ -8035,7 +9391,7 @@ void main() { .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; } .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; } - /* --- Shadow tile --- */ + /* Shadow tile */ .s-tile { } .s-surface { height: 60px; margin: 8px 2px 10px; @@ -8045,14 +9401,14 @@ void main() { .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; } .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; } - /* --- Radii strip --- */ + /* Radii strip */ .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; } .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; } .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); } .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; } .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; } - /* --- Component tile (hosts live primitives) --- */ + /* Component tile (hosts live primitives) */ .cmp-tile { } .cmp-stage { margin: 12px -4px 0; @@ -8066,7 +9422,7 @@ void main() { .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; } .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; } - /* --- Collapsible --- */ + /* Collapsible */ .coll { margin: 0 4px 8px; background: ${DP.tile}; @@ -8131,7 +9487,7 @@ void main() { .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; } .coll .overview-body li { margin-bottom: 3px; } - /* --- raw tab markdown (unchanged layout, neutralized palette) --- */ + /* raw tab markdown (unchanged layout, neutralized palette) */ .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; } .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; } .md h1 { font-size: 18px; } @@ -8303,7 +9659,7 @@ void main() { return box; } - // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 --- + // Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 function renderDesignVisual(body, parsed, sidecar) { const frontmatter = parsed?.frontmatter || {}; @@ -8667,7 +10023,7 @@ void main() { return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components'); } - // --- Collapsibles --------------------------------------------------------- + // Collapsibles. function buildCollapsible(key, label, count) { const wrap = document.createElement('div'); @@ -8775,7 +10131,7 @@ void main() { return s.replace(/\s+#.*$/, '').trim(); } - // --- Raw tab: minimal markdown renderer (subset) -------------------------- + // Raw tab: minimal markdown renderer (subset) function renderRawTab(body, md) { const wrap = document.createElement('div'); @@ -8908,9 +10264,9 @@ void main() { } catch { /* ignore */ } } - // --------------------------------------------------------------------------- + // // Init - // --------------------------------------------------------------------------- + // function init() { try { history.scrollRestoration = 'manual'; } catch {} diff --git a/skill/scripts/live-completion.mjs b/skill/scripts/live-completion.mjs index 86b637fff..986773066 100644 --- a/skill/scripts/live-completion.mjs +++ b/skill/scripts/live-completion.mjs @@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) { if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done'; if (acceptResult?.handled === true) return 'complete'; if (acceptResult?.mode === 'error') return 'error'; + if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error'; return 'agent_done'; } diff --git a/skill/scripts/live-inject.mjs b/skill/scripts/live-inject.mjs index b9d3df41d..3a1f36e46 100644 --- a/skill/scripts/live-inject.mjs +++ b/skill/scripts/live-inject.mjs @@ -17,11 +17,38 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './impeccable-paths.mjs'; +import { + applySvelteKitLiveAdapter, + detectSvelteKitProject, + removeSvelteKitLiveAdapter, +} from './live-sveltekit-adapter.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start'; +const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end'; + +export const LIVE_IGNORE_PATTERNS = Object.freeze([ + '.impeccable/hook.cache.json', + '.impeccable/live/server.json', + '.impeccable/live/sessions/', + '.impeccable/live/previews/', + '.impeccable/live/annotations/', + '.impeccable/live/cache/', + '.impeccable/live/manual-edit-apply-transaction.json', + '.impeccable/live/manual-edit-events.jsonl', + '.impeccable/live/manual-edit-evidence/', + '.impeccable/live/pending-manual-edits.json', + '.impeccable/live/deferred-svelte-component-accepts.json', + '.impeccable-live.json', + '.impeccable-live/', + 'node_modules/.impeccable-live/', + 'src/lib/impeccable/ImpeccableLiveRoot.svelte', + 'src/lib/impeccable/__runtime.js', + 'src/lib/impeccable/[0-9a-f]*/', +]); /** * Hard-excluded directory patterns. These are NEVER user-facing pages and @@ -83,8 +110,14 @@ Output (JSON): validateConfig(config); const resolvedFiles = resolveFiles(process.cwd(), config); + const svelteKit = detectSvelteKitProject(process.cwd(), config); if (args.includes('--remove')) { + if (svelteKit) { + const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config }); + console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; @@ -110,6 +143,13 @@ Output (JSON): console.error(JSON.stringify({ ok: false, error: 'missing_port' })); process.exit(1); } + const gitIgnore = ensureLiveGitIgnores(process.cwd()); + + if (svelteKit) { + const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config }); + console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] })); + return; + } const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); @@ -129,10 +169,68 @@ Output (JSON): }; }); const anyInserted = results.some((r) => r.inserted); - console.log(JSON.stringify({ ok: anyInserted, port, results })); + console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results })); if (!anyInserted) process.exit(1); } +export function ensureLiveGitIgnores(cwd = process.cwd()) { + const target = resolveIgnoreTarget(cwd); + const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : ''; + const block = [ + IGNORE_MARKER_OPEN, + ...LIVE_IGNORE_PATTERNS, + IGNORE_MARKER_CLOSE, + ].join('\n'); + const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`); + + let updated; + if (markerRe.test(existing)) { + updated = existing.replace(markerRe, block); + } else { + const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n'; + updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`; + } + + if (updated !== existing) { + fs.mkdirSync(path.dirname(target.path), { recursive: true }); + fs.writeFileSync(target.path, updated, 'utf-8'); + } + + return { + file: path.relative(cwd, target.path).split(path.sep).join('/'), + mode: target.mode, + changed: updated !== existing, + patterns: [...LIVE_IGNORE_PATTERNS], + }; +} + +function resolveIgnoreTarget(cwd) { + const gitExcludePath = resolveGitInfoExcludePath(cwd); + if (gitExcludePath) { + return { path: gitExcludePath, mode: 'git-info-exclude' }; + } + return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' }; +} + +function resolveGitInfoExcludePath(cwd) { + const dotGit = path.join(cwd, '.git'); + if (!fs.existsSync(dotGit)) return null; + + const stat = fs.statSync(dotGit); + if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude'); + if (!stat.isFile()) return null; + + const body = fs.readFileSync(dotGit, 'utf-8').trim(); + const match = body.match(/^gitdir:\s*(.+)$/i); + if (!match) return null; + const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]); + return path.join(gitDir, 'info', 'exclude'); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Expand config.files (which may contain glob patterns) into a literal list * of existing file paths relative to rootDir. Literal entries pass through; diff --git a/skill/scripts/live-insert.mjs b/skill/scripts/live-insert.mjs index 09d4d55be..0658e9914 100644 --- a/skill/scripts/live-insert.mjs +++ b/skill/scripts/live-insert.mjs @@ -21,6 +21,11 @@ import { buildCssAuthoring, buildCssSelectorPrefixExamples, } from './live-wrap.mjs'; +import { + buildSvelteComponentCssAuthoring, + scaffoldSvelteComponentInsertSession, + shouldUseSvelteComponentInjection, +} from './live-svelte-component.mjs'; const INSERT_POSITIONS = new Set(['before', 'after']); @@ -192,6 +197,41 @@ Output (JSON): const styleMode = detectStyleMode(targetFile); const isJsx = commentSyntax.open === '{/*'; const spliceIndex = computeInsertLine(startLine, endLine, position); + const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/'); + + if (shouldUseSvelteComponentInjection(targetFile)) { + const session = scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile: relTargetFile, + insertLine: spliceIndex + 1, + position, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + anchorLines: lines.slice(startLine, endLine + 1), + cwd: process.cwd(), + }); + console.log(JSON.stringify({ + mode: 'insert', + position, + file: session.manifestFile, + sourceFile: relTargetFile, + previewMode: 'svelte-component', + componentDir: session.componentDir, + propContract: session.propContract, + insertLine: 1, + sourceInsertLine: spliceIndex + 1, + anchorStartLine: startLine + 1, + anchorEndLine: endLine + 1, + commentSyntax, + styleMode: 'svelte-component', + styleTag: null, + cssSelectorPrefixExamples: [], + cssAuthoring: buildSvelteComponentCssAuthoring(count), + })); + return; + } + const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1] ?? lines[startLine]?.match(/^(\s*)/)?.[1] ?? ''; @@ -216,7 +256,7 @@ Output (JSON): console.log(JSON.stringify({ mode: 'insert', position, - file: path.relative(process.cwd(), targetFile), + file: relTargetFile, insertLine: insertLine + 1, commentSyntax, styleMode: styleMode.mode, diff --git a/skill/scripts/live-poll.mjs b/skill/scripts/live-poll.mjs index fad836612..1e1259fbc 100644 --- a/skill/scripts/live-poll.mjs +++ b/skill/scripts/live-poll.mjs @@ -20,6 +20,7 @@ import { readLiveServerInfo } from './impeccable-paths.mjs'; // that ceiling and loop in `pollOnce` to synthesize a long poll without // depending on the standalone undici package. export const PER_REQUEST_TIMEOUT_MS = 270_000; +export const DEFAULT_EVENT_LEASE_MS = 600_000; const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); @@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) { ? totalDeadline - Date.now() : PER_REQUEST_TIMEOUT_MS; const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); - const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`); + const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`); if (res.status === 401) { const err = new Error('Authentication failed. The server token may have changed.'); @@ -317,7 +318,7 @@ Modes: Options: --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) - --file PATH Attach a source file path to the reply (generate flow) + --file PATH Attach a source file path to the reply (generate/steer flow) --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON --help Show this help message diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index 16c8285b9..cd1091b88 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/scripts/live-server.mjs @@ -42,6 +42,10 @@ import { } from './live-manual-edits-buffer.mjs'; import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs'; import { commitManualEdits } from './live-commit-manual-edits.mjs'; +import { + applyDeferredSvelteComponentAccepts, + removeAllSvelteComponentSessions, +} from './live-svelte-component.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated @@ -103,6 +107,7 @@ const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1; const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20; const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240; const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4; +const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2; const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || ''); function tombstoneTimedOutApplyId(eventId, details = {}) { @@ -897,6 +902,8 @@ function leaseEvent(entry, leaseMs) { return entry.event; } entry.leaseUntil = Date.now() + leaseMs; + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return entry.event; } @@ -907,9 +914,16 @@ function acknowledgePendingEvent(id) { const acknowledged = state.pendingEvents[idx].event; state.pendingEvents.splice(idx, 1); scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); return acknowledged; } +function findPendingEventById(id) { + if (!id) return null; + const entry = state.pendingEvents.find((item) => item.event?.id === id); + return entry?.event || null; +} + function manualApplyReplyCommand(eventOrId = 'EVENT_ID') { const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID'; return `live-poll.mjs --reply ${id} done --data ''`; @@ -955,6 +969,42 @@ function summarizePendingEventForStatus(entry) { return summary; } +function summarizeActiveSessionForClient(snapshot = {}) { + return { + id: snapshot.id, + phase: snapshot.phase, + pageUrl: snapshot.pageUrl ?? null, + sourceFile: snapshot.sourceFile ?? null, + previewFile: snapshot.previewFile ?? null, + previewMode: snapshot.previewMode ?? null, + expectedVariants: snapshot.expectedVariants ?? 0, + arrivedVariants: snapshot.arrivedVariants ?? 0, + visibleVariant: snapshot.visibleVariant ?? null, + checkpointRevision: snapshot.checkpointRevision ?? 0, + paramValues: snapshot.paramValues || {}, + }; +} + +function activeSessionSummaries() { + if (!state.sessionStore) return []; + return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot)); +} + +function cancelQueuedAnonymousExitEvents() { + let removed = 0; + for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) { + const event = state.pendingEvents[i]?.event; + if (event?.type !== 'exit' || event.id) continue; + state.pendingEvents.splice(i, 1); + removed += 1; + } + if (removed > 0) { + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); + } + return removed; +} + function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') { const canceledById = new Map(); const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl); @@ -1001,7 +1051,6 @@ function scheduleLeaseFlush() { clearTimeout(state.leaseTimer); state.leaseTimer = null; } - if (state.pendingPolls.length === 0) return; const now = Date.now(); const nextLeaseUntil = state.pendingEvents .map((entry) => entry.leaseUntil || 0) @@ -1011,7 +1060,8 @@ function scheduleLeaseFlush() { state.leaseTimer = setTimeout(() => { state.leaseTimer = null; flushPendingPolls(); - }, Math.max(0, nextLeaseUntil - now)); + broadcastAgentPollingIfChanged(); + }, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS)); } function flushPendingPolls() { @@ -1032,7 +1082,9 @@ function flushPendingPolls() { } function agentPollingConnected() { - return state.pendingPolls.length > 0; + const now = Date.now(); + return state.pendingPolls.length > 0 + || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now); } function broadcastAgentPollingIfChanged() { @@ -1318,7 +1370,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/status') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; } - const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : []; + const sessions = activeSessionSummaries(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', @@ -1423,6 +1475,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { if (p === '/events' && req.method === 'GET') { const token = url.searchParams.get('token'); if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + clearTimeout(state.exitTimer); + state.exitTimer = null; + cancelQueuedAnonymousExitEvents(); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -1432,10 +1487,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { type: 'connected', hasProjectContext: hasProjectContext(), agentPolling: agentPollingConnected(), + activeSessions: activeSessionSummaries(), }) + '\n\n'); state.sseClients.add(res); - clearTimeout(state.exitTimer); // Keepalive: SSE comment every 30s prevents silent connection drops. const heartbeat = setInterval(() => { @@ -1827,6 +1882,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) { return; } } + if (msg.type === 'exit') { + cleanupSvelteComponentSessionsBeforeExit(); + } if (msg.type !== 'checkpoint') { enqueueEvent(msg); } @@ -1905,6 +1963,36 @@ function handlePollGet(req, res, url) { }); } +function sessionFileMetadataFromPollReply(file) { + if (!file || typeof file !== 'string') return { file }; + const normalized = file.split(path.sep).join('/'); + const base = { file: normalized }; + if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base; + if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base; + + let full; + try { + full = path.resolve(process.cwd(), normalized); + const rel = path.relative(process.cwd(), full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base; + } catch { + return base; + } + + try { + const manifest = JSON.parse(fs.readFileSync(full, 'utf-8')); + if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base; + return { + file: String(manifest.sourceFile).split(path.sep).join('/'), + sourceFile: String(manifest.sourceFile).split(path.sep).join('/'), + previewFile: normalized, + previewMode: 'svelte-component', + }; + } catch { + return base; + } +} + function handlePollPost(req, res) { let body = ''; req.on('data', (c) => { body += c; }); @@ -1965,6 +2053,16 @@ function handlePollPost(req, res) { res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback })); return; } + const pendingEventBeforeAck = findPendingEventById(msg.id); + if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done' + && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'steer_done_requires_file_or_message', + hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.', + })); + return; + } const acknowledgedEvent = acknowledgePendingEvent(msg.id); let skipJournalReply = false; let existingSession = null; @@ -1987,6 +2085,7 @@ function handlePollPost(req, res) { })); return; } + const replyFileMeta = sessionFileMetadataFromPollReply(msg.file); if (state.sessionStore && msg.id && !skipJournalReply) { try { const eventType = msg.type === 'steer_done' @@ -2001,7 +2100,10 @@ function handlePollPost(req, res) { state.sessionStore.appendEvent({ type: eventType, id: msg.id, - file: msg.file, + file: replyFileMeta.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, message: msg.message, sourceEventType: acknowledgedEvent?.type, carbonize: msg.data?.carbonize === true, @@ -2010,7 +2112,16 @@ function handlePollPost(req, res) { } flushPendingPolls(); // Forward the reply to the browser via SSE - broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + broadcast({ + type: msg.type || 'done', + id: msg.id, + message: msg.message, + file: msg.file, + sourceFile: replyFileMeta.sourceFile, + previewFile: replyFileMeta.previewFile, + previewMode: replyFileMeta.previewMode, + data: msg.data, + }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); @@ -2023,6 +2134,7 @@ function handlePollPost(req, res) { let httpServer = null; function shutdown() { + cleanupSvelteComponentSessionsBeforeExit(); removeLiveServerInfo(process.cwd()); if (state.leaseTimer) clearTimeout(state.leaseTimer); state.leaseTimer = null; @@ -2037,6 +2149,25 @@ function shutdown() { process.exit(0); } +function cleanupSvelteComponentSessionsBeforeExit() { + try { + removeAllSvelteComponentSessions(process.cwd()); + } catch (err) { + console.warn('[impeccable] Svelte component session cleanup failed:', err.message); + } +} + +function applyLegacyDeferredAcceptsOnStartup() { + try { + const result = applyDeferredSvelteComponentAccepts(process.cwd()); + if (result.applied > 0 || result.failed > 0) { + console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result)); + } + } catch (err) { + console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -2162,6 +2293,7 @@ rollbackManualApplyTransaction({ cwd: process.cwd(), reason: 'manual_edit_server_start_recovered_abandoned_transaction', }); +applyLegacyDeferredAcceptsOnStartup(); restorePendingEventsFromStore(); pruneStaleManualApplyEvidence(process.cwd()); const portArg = args.find(a => a.startsWith('--port=')); diff --git a/skill/scripts/live-session-store.mjs b/skill/scripts/live-session-store.mjs index 7562e3d5b..5ec4d34d4 100644 --- a/skill/scripts/live-session-store.mjs +++ b/skill/scripts/live-session-store.mjs @@ -106,6 +106,8 @@ function baseSnapshot(id) { phase: 'new', pageUrl: null, sourceFile: null, + previewFile: null, + previewMode: null, expectedVariants: 0, arrivedVariants: 0, visibleVariant: null, @@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { case 'variants_ready': case 'agent_done': next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready'; - next.sourceFile = event.file ?? next.sourceFile; - next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants); + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0); next.pendingEventSeq = null; next.pendingEvent = null; if (event.carbonize === true) { @@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { } break; case 'checkpoint': + if (COMPLETED_PHASES.has(next.phase)) { + next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null }); + break; + } if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) { next.phase = event.phase ?? next.phase; next.checkpointRevision = event.revision ?? next.checkpointRevision; next.activeOwner = event.owner ?? next.activeOwner; next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants; next.visibleVariant = event.visibleVariant ?? next.visibleVariant; + next.sourceFile = event.sourceFile ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; if (event.paramValues) next.paramValues = { ...event.paramValues }; } else { next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision }); @@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'steer_done': next.phase = 'steer_done'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; + next.message = event.message ?? next.message; next.pendingEventSeq = null; next.pendingEvent = null; break; @@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) { break; case 'complete': next.phase = 'completed'; + next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile; + next.previewFile = event.previewFile ?? next.previewFile; + next.previewMode = event.previewMode ?? next.previewMode; next.pendingEventSeq = null; next.pendingEvent = null; break; diff --git a/skill/scripts/live-svelte-component.mjs b/skill/scripts/live-svelte-component.mjs new file mode 100644 index 000000000..dc35dc0ce --- /dev/null +++ b/skill/scripts/live-svelte-component.mjs @@ -0,0 +1,826 @@ +/** + * Svelte live-mode component injection helpers. + * + * Variants are real .svelte components under node_modules/.impeccable-live//. + * The browser mounts them via Svelte 5 mount(); accept inlines the chosen + * variant back into the route source with props mapped to original bindings. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; + +export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live'; +export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`; +export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json'; + +const MUSTACHE_RE = /\{([^{}]+)\}/g; + +export function shouldUseSvelteComponentInjection(filePath) { + if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false; + return path.extname(filePath).toLowerCase() === '.svelte'; +} + +export function componentSessionDir(id, cwd = process.cwd()) { + return path.join(cwd, SVELTE_COMPONENT_ROOT, id); +} + +export function manifestPathForSession(id, cwd = process.cwd()) { + return path.join(componentSessionDir(id, cwd), 'manifest.json'); +} + +export function ensureRuntimeHelper(cwd = process.cwd()) { + const file = path.join(cwd, SVELTE_RUNTIME_FILE); + if (fs.existsSync(file)) return file; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8'); + return file; +} + +/** + * Extract ordered unique mustache expressions from markup (not inside ). + */ +export function extractMustacheExpressions(text) { + const expressions = []; + const seen = new Set(); + const lines = String(text || '').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('\n` + : ''; + return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n\n`; +} + +function buildInsertVariantStub(variantNum) { + return `${buildPropsScript([])}
Insert variant ${variantNum}
\n\n\n`; +} + +export function scaffoldSvelteComponentSession({ + id, + count, + sourceFile, + sourceStartLine, + sourceEndLine, + originalLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const originalMarkup = originalLines.join('\n'); + const contract = buildPropContract(extractMustacheExpressions(originalMarkup)); + const originalWithProps = substituteExprsWithProps(originalMarkup, contract); + + const manifest = { + id, + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + sourceStartLine, + sourceEndLine, + count, + propContract: contract, + originalMarkup, + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: contract, + }; +} + +export function scaffoldSvelteComponentInsertSession({ + id, + count, + sourceFile, + insertLine, + position, + anchorStartLine, + anchorEndLine, + anchorLines, + cwd = process.cwd(), +}) { + ensureRuntimeHelper(cwd); + const dir = componentSessionDir(id, cwd); + fs.mkdirSync(dir, { recursive: true }); + + const anchorMarkup = (anchorLines || []).join('\n'); + const manifest = { + id, + mode: 'insert', + previewMode: 'svelte-component', + sourceFile: sourceFile.split(path.sep).join('/'), + insertLine, + position, + anchorStartLine, + anchorEndLine, + originalMarkup: anchorMarkup, + anchorMarkup, + count, + propContract: [], + componentDir: path.relative(cwd, dir).split(path.sep).join('/'), + runtimeModule: `/${SVELTE_RUNTIME_FILE}`, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + + for (let n = 1; n <= count; n++) { + const variantFile = path.join(dir, `v${n}.svelte`); + if (!fs.existsSync(variantFile)) { + fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8'); + } + } + + return { + manifest, + manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'), + componentDir: manifest.componentDir, + propContract: [], + }; +} + +export function findSvelteComponentManifest(id, cwd = process.cwd()) { + const direct = manifestPathForSession(id, cwd); + if (fs.existsSync(direct)) { + return readManifest(direct); + } + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return null; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(root, entry.name, 'manifest.json'); + if (!fs.existsSync(candidate)) continue; + try { + const manifest = readManifest(candidate); + if (manifest?.id === id) return { ...manifest, manifestPath: candidate }; + } catch { /* skip */ } + } + return null; +} + +export function readManifest(manifestPath) { + const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + return { + ...data, + manifestPath, + }; +} + +export function resolveSourceFile(sourceFile, cwd = process.cwd()) { + if (!sourceFile || path.isAbsolute(sourceFile)) { + throw new Error('Invalid svelte-component source file'); + } + const full = path.resolve(cwd, sourceFile); + const rel = path.relative(cwd, full); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('Svelte-component source file escapes project root'); + } + if (!fs.existsSync(full)) { + throw new Error('Svelte-component source file not found: ' + sourceFile); + } + return full; +} + +function appendCssToSvelteStyle(lines, cssLines) { + const closeIdx = findLastStyleCloseLine(lines); + const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))]; + if (closeIdx === -1) { + return [...lines, '', '']; + } + return [ + ...lines.slice(0, closeIdx), + ...prepared, + ...lines.slice(closeIdx), + ]; +} + +function findLastStyleCloseLine(lines) { + for (let i = lines.length - 1; i >= 0; i--) { + if (/<\/style\s*>/.test(lines[i])) return i; + } + return -1; +} + +function bakeParamValuesInCss(cssLines, paramValues) { + if (!paramValues || Object.keys(paramValues).length === 0) return cssLines; + return cssLines.map((line) => { + let out = line; + for (const [key, value] of Object.entries(paramValues)) { + const varName = `--p-${key}`; + out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value)); + } + return out; + }); +} + +function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') { + const css = String((cssLines || []).join('\n')); + if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines; + + const rules = parseCssRules(css); + const output = []; + for (const rule of rules) { + appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag); + } + return output.join('\n') + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ''); +} + +function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) { + const prelude = rule.prelude.trim(); + const body = rule.body.trim(); + if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return; + + if (/^@scope\b/i.test(prelude)) { + if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return; + const inner = parseCssRules(body); + for (const innerRule of inner) { + const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true); + if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue; + output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim())); + } + return; + } + + const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false); + if (!rewrittenPrelude) return; + output.push(formatCssRule(rewrittenPrelude, body)); +} + +function parseCssRules(css) { + const rules = []; + const text = String(css || ''); + let i = 0; + while (i < text.length) { + while (i < text.length && /\s/.test(text[i])) i++; + const preludeStart = i; + while (i < text.length && text[i] !== '{') i++; + if (i >= text.length) break; + const prelude = text.slice(preludeStart, i).trim(); + i++; + const bodyStart = i; + let depth = 1; + let quote = null; + let comment = false; + while (i < text.length && depth > 0) { + const ch = text[i]; + const next = text[i + 1]; + if (comment) { + if (ch === '*' && next === '/') { + comment = false; + i += 2; + continue; + } + i++; + continue; + } + if (quote) { + if (ch === '\\') { + i += 2; + continue; + } + if (ch === quote) quote = null; + i++; + continue; + } + if (ch === '/' && next === '*') { + comment = true; + i += 2; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = text.slice(bodyStart, Math.max(bodyStart, i - 1)); + if (prelude) rules.push({ prelude, body }); + } + return rules; +} + +function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) { + const selectors = splitSelectorList(prelude); + const rewritten = []; + for (const selector of selectors) { + const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope); + if (next) rewritten.push(next); + } + return rewritten.join(', '); +} + +function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) { + let out = selector.trim(); + const hasVariant = /data-impeccable-variant/.test(out); + if (hasVariant && !selectorHasVariant(out, variantNum)) return ''; + if (hasVariant) { + out = out.replace(variantSelectorRegex(variantNum), ''); + out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, ''); + } + + const paramResult = rewriteParamSelectors(out, paramValues); + if (!paramResult.keep) return ''; + out = paramResult.selector; + + out = out + .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') + .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '') + .replace(/\s+/g, ' ') + .trim(); + + out = out.replace(/^[>+~]\s*/, '').trim(); + if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)'; + return out; +} + +function rewriteParamSelectors(selector, paramValues) { + let keep = true; + const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => { + if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return ''; + const actual = paramValues[key]; + if (expected != null && String(actual) !== String(expected)) { + keep = false; + return ''; + } + if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) { + keep = false; + return ''; + } + return ''; + }); + return { keep, selector: next }; +} + +function splitSelectorList(prelude) { + const selectors = []; + let start = 0; + let bracket = 0; + let paren = 0; + let quote = null; + for (let i = 0; i < prelude.length; i++) { + const ch = prelude[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '[') bracket++; + else if (ch === ']') bracket = Math.max(0, bracket - 1); + else if (ch === '(') paren++; + else if (ch === ')') paren = Math.max(0, paren - 1); + else if (ch === ',' && bracket === 0 && paren === 0) { + selectors.push(prelude.slice(start, i)); + start = i + 1; + } + } + selectors.push(prelude.slice(start)); + return selectors; +} + +function selectorHasVariant(selector, variantNum) { + return variantSelectorRegex(variantNum).test(selector); +} + +function variantSelectorRegex(variantNum) { + return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g'); +} + +function formatCssRule(selector, body) { + return `${selector} { ${body.trim()} }`; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) { + const sourceFile = resolveSourceFile(manifest.sourceFile, cwd); + const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`); + const resultBase = { + file: manifest.sourceFile, + sourceFile: manifest.sourceFile, + previewMode: 'svelte-component', + componentDir: manifest.componentDir, + carbonize: false, + }; + if (!fs.existsSync(variantPath)) { + return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase }; + } + + const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8')); + if (manifest.mode === 'insert') { + return inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, + }); + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const contract = manifest.propContract || []; + const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || ''); + const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract) + .split('\n') + .map((line) => line.trimEnd()); + + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const start = Number(manifest.sourceStartLine) - 1; + const end = Number(manifest.sourceEndLine) - 1; + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) { + return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase }; + } + + const indent = sourceLines[start].match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, start), + ...indentedMarkup, + ...sourceLines.slice(end + 1), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function inlineSvelteComponentInsertAccept({ + manifest, + markup, + cssLines, + variantNum, + paramValues, + sourceFile, + resultBase, + cwd, +}) { + if (!svelteMarkupHasVisibleContent(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase }; + } + if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) { + return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase }; + } + + const rootTag = matchOpeningTag(markup)?.tag || 'div'; + const restoredMarkup = String(markup || '') + .split('\n') + .map((line) => line.trimEnd()); + const sourceContent = fs.readFileSync(sourceFile, 'utf-8'); + const sourceLines = sourceContent.split('\n'); + const insertIndex = Number(manifest.insertLine) - 1; + if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) { + return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase }; + } + + const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? ''; + const indent = nearbyLine.match(/^(\s*)/)?.[1] || ''; + const indentedMarkup = restoredMarkup.map((line) => { + if (line.trim() === '') return ''; + return indent + line.trimStart(); + }); + + let newLines = [ + ...sourceLines.slice(0, insertIndex), + ...indentedMarkup, + ...sourceLines.slice(insertIndex), + ]; + + const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag); + const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues); + if (bakedCss.length > 0) { + newLines = appendCssToSvelteStyle(newLines, bakedCss); + } + + try { + fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + } catch (err) { + return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; + } + removeSvelteComponentSession(manifest.id, cwd); + + return { + handled: true, + ...resultBase, + }; +} + +function svelteMarkupHasVisibleContent(markup) { + const text = String(markup || '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 0) return true; + return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); +} + +function mergeOriginalTopLevelAttrs(markup, originalMarkup) { + const variantOpen = matchOpeningTag(markup); + const originalOpen = matchOpeningTag(originalMarkup); + if (!variantOpen || !originalOpen) return markup; + if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup; + + const variantAttrs = parseAttrSegments(variantOpen.attrs); + const originalAttrs = parseAttrSegments(originalOpen.attrs); + const additions = []; + let attrs = variantOpen.attrs; + + const originalClass = originalAttrs.get('class'); + const variantClass = variantAttrs.get('class'); + if (originalClass && variantClass) { + const merged = mergeStaticClassAttr(originalClass, variantClass); + if (merged) { + attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end); + variantAttrs.set('class', { ...variantClass, raw: merged }); + } + } else if (originalClass && !variantClass) { + additions.push(originalClass.raw); + } + + for (const [name, attr] of originalAttrs) { + if (name === 'class') continue; + if (!variantAttrs.has(name)) additions.push(attr.raw); + } + + if (additions.length === 0 && attrs === variantOpen.attrs) return markup; + const nextOpen = variantOpen.prefix + + variantOpen.tag + + attrs + + additions.map((attr) => ' ' + attr.trim()).join('') + + variantOpen.close; + return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length); +} + +function matchOpeningTag(markup) { + const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/); + if (!match) return null; + return { + raw: match[0], + prefix: match[1], + tag: match[2], + attrs: match[3] || '', + close: match[4], + index: match.index || 0, + }; +} + +function parseAttrSegments(attrs) { + const out = new Map(); + const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g; + let match; + while ((match = re.exec(attrs))) { + const raw = match[0]; + const name = match[1]; + out.set(name, { + name, + raw, + start: match.index, + end: match.index + raw.length, + }); + } + return out; +} + +function mergeStaticClassAttr(originalClass, variantClass) { + const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/); + if (!originalValue || !variantValue) return null; + const quote = variantValue[1]; + const classes = [ + ...variantValue[2].split(/\s+/), + ...originalValue[2].split(/\s+/), + ].filter(Boolean); + return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`; +} + +export function removeSvelteComponentSession(id, cwd = process.cwd()) { + const dir = componentSessionDir(id, cwd); + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* non-fatal */ } +} + +export function removeAllSvelteComponentSessions(cwd = process.cwd()) { + const root = path.join(cwd, SVELTE_COMPONENT_ROOT); + if (!fs.existsSync(root)) return; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('__')) continue; + try { + fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + } catch { /* non-fatal */ } + } +} + +export function deferredAcceptsPath(cwd = process.cwd()) { + const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16); + return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json'); +} + +export function readDeferredAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch { + return { accepts: [] }; + } +} + +export function writeDeferredAccept(entry, cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const data = readDeferredAccepts(cwd); + data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id); + data.accepts.push({ ...entry, createdAt: new Date().toISOString() }); + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) { + const file = deferredAcceptsPath(cwd); + const data = readDeferredAccepts(cwd); + const pending = Array.isArray(data.accepts) ? data.accepts : []; + const results = []; + const remaining = []; + for (const entry of pending) { + try { + const manifest = findSvelteComponentManifest(entry.id, cwd); + if (!manifest) { + results.push({ id: entry.id, ok: false, error: 'manifest not found' }); + remaining.push(entry); + continue; + } + const result = inlineSvelteComponentAccept( + manifest, + entry.variantNum, + entry.paramValues || null, + cwd, + ); + results.push({ id: entry.id, ok: result.handled !== false, result }); + if (result.handled === false) remaining.push(entry); + } catch (err) { + results.push({ id: entry.id, ok: false, error: err.message }); + remaining.push(entry); + } + } + if (remaining.length > 0) { + fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8'); + } else { + try { fs.rmSync(file, { force: true }); } catch {} + } + return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results }; +} + +export function buildSvelteComponentCssAuthoring(count) { + const variantNumbers = Array.from({ length: count }, (_, i) => i + 1); + return { + mode: 'svelte-component', + styleTag: null, + strategy: 'component-style-block', + rulePattern: '.semantic-class { ... }', + selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'), + requirements: [ + 'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).', + 'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.', + 'Put variant CSS in the component diff --git a/tests/framework-fixtures/vite8-sveltekit-stateful/files/svelte.config.js b/tests/framework-fixtures/vite8-sveltekit-stateful/files/svelte.config.js new file mode 100644 index 000000000..bbc8b7164 --- /dev/null +++ b/tests/framework-fixtures/vite8-sveltekit-stateful/files/svelte.config.js @@ -0,0 +1,7 @@ +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess(), + kit: { adapter: adapter() }, +}; diff --git a/tests/framework-fixtures/vite8-sveltekit-stateful/files/vite.config.js b/tests/framework-fixtures/vite8-sveltekit-stateful/files/vite.config.js new file mode 100644 index 000000000..05feadbc1 --- /dev/null +++ b/tests/framework-fixtures/vite8-sveltekit-stateful/files/vite.config.js @@ -0,0 +1,7 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { host: '127.0.0.1', strictPort: false }, +}); diff --git a/tests/framework-fixtures/vite8-sveltekit-stateful/fixture.json b/tests/framework-fixtures/vite8-sveltekit-stateful/fixture.json new file mode 100644 index 000000000..0fee6a8f8 --- /dev/null +++ b/tests/framework-fixtures/vite8-sveltekit-stateful/fixture.json @@ -0,0 +1,26 @@ +{ + "name": "Vite 8 + SvelteKit stateful page", + "config": { + "files": ["src/app.html"], + "insertBefore": "", + "commentSyntax": "html" + }, + "sourceFiles": ["DESIGN.md", "src/app.html", "src/routes/+page.svelte", "src/routes/+layout.svelte", "svelte.config.js", "vite.config.js"], + "generatedFiles": [], + "wrapCases": [ + { + "name": "wraps hero title through Svelte component preview", + "args": { "classes": "hero-title", "tag": "h1" }, + "expectedFile": "node_modules/.impeccable-live/wraptest0/manifest.json", + "expectedSourceFile": "src/routes/+page.svelte", + "expectedPreviewMode": "svelte-component" + }, + { + "name": "wraps stateful expense row through Svelte component preview", + "args": { "classes": "expense-row", "tag": "article" }, + "expectedFile": "node_modules/.impeccable-live/wraptest1/manifest.json", + "expectedSourceFile": "src/routes/+page.svelte", + "expectedPreviewMode": "svelte-component" + } + ] +} diff --git a/tests/framework-fixtures/vite8-sveltekit-stateful/gitignore.txt b/tests/framework-fixtures/vite8-sveltekit-stateful/gitignore.txt new file mode 100644 index 000000000..7645cd701 --- /dev/null +++ b/tests/framework-fixtures/vite8-sveltekit-stateful/gitignore.txt @@ -0,0 +1,5 @@ +node_modules/ +.svelte-kit/ +build/ +dist/ +package-lock.json diff --git a/tests/framework-fixtures/vite8-sveltekit/fixture.json b/tests/framework-fixtures/vite8-sveltekit/fixture.json index 656fca870..a75956e0a 100644 --- a/tests/framework-fixtures/vite8-sveltekit/fixture.json +++ b/tests/framework-fixtures/vite8-sveltekit/fixture.json @@ -11,7 +11,9 @@ { "name": "wraps hero title in route source", "args": { "classes": "hero-title", "tag": "h1" }, - "expectedFile": "src/routes/+page.svelte" + "expectedFile": "node_modules/.impeccable-live/wraptest0/manifest.json", + "expectedSourceFile": "src/routes/+page.svelte", + "expectedPreviewMode": "svelte-component" } ], "runtime": { diff --git a/tests/live-accept.test.mjs b/tests/live-accept.test.mjs index d8cff6597..e96b92dfe 100644 --- a/tests/live-accept.test.mjs +++ b/tests/live-accept.test.mjs @@ -169,6 +169,11 @@ describe('live-accept — style-element edge cases', () => { assert.equal(closeCount, 1, `expected exactly one \`} closer, got ${closeCount}`); // CSS content survived intact. assert.ok(inner.includes('@scope ([data-impeccable-variant="1"])'), 'variant-1 scope kept'); + assert.match( + after, + /\n
\n

variant one<\/p>\n <\/div>/, + 'accepted JSX content is indented inside the temporary carbonize variant wrapper', + ); }); // Same shape, but the agent put `{`` and ``\`}` attached to first/last CSS @@ -207,6 +212,41 @@ describe('live-accept — style-element edge cases', () => { assert.ok(inner.includes('@scope ([data-impeccable-variant="1"])'), 'variant-1 scope kept'); }); + it('carbonize preserves nested JSX indentation when the wrapper starts at column 0', () => { + const tsx = `

+ {/* impeccable-variants-start ROOTIND */} +
+
+ original +
+
+ +
+
+ nested text +
+
+
+
+ variant two +
+
+ {/* impeccable-variants-end ROOTIND */} +
+`; + writeFileSync(join(tmp, 'Root.tsx'), tsx); + + const result = runAccept(tmp, ['--id', 'ROOTIND', '--variant', '1']); + assert.equal(result.handled, true, `accept should succeed: ${JSON.stringify(result)}`); + + const after = readFileSync(join(tmp, 'Root.tsx'), 'utf-8'); + assert.match( + after, + /
\n
\n nested text<\/span>\n <\/section>\n <\/div>/, + 'column-0 JSX accept preserves relative indentation inside the carbonize variant wrapper', + ); + }); + // Cursor Bugbot regression (PR #118 review): the JSX wrapper places // marker comments INSIDE the outer
, so block.start sits 2 spaces // deeper than the original element. Using block.start as the deindent diff --git a/tests/live-browser-regression.test.mjs b/tests/live-browser-regression.test.mjs index 3cd986fdf..3858d266f 100644 --- a/tests/live-browser-regression.test.mjs +++ b/tests/live-browser-regression.test.mjs @@ -74,6 +74,44 @@ describe('live-browser.js regression guards', () => { ); }); + it('uses a Svelte-gated painted-ancestor crop proxy for shader capture', () => { + assert.match( + SOURCE, + /function findShaderProxyCaptureRoot\(el\) \{[\s\S]{0,500}?let node = el\.parentElement;[\s\S]{0,700}?containsElement && paintsShaderProxySurface\(node\)[\s\S]{0,120}?return null;/, + 'shader proxy should choose the nearest painted ancestor, not the document root', + ); + assert.match( + SOURCE, + /async function captureElementFromRenderedAncestor\(ms, el, opts\) \{[\s\S]{0,360}?const captureRoot = findShaderProxyCaptureRoot\(el\);[\s\S]{0,220}?ms\.domToCanvas\(captureRoot, opts\)[\s\S]{0,900}?cctx\.drawImage\(rootCanvas, sx, sy, sw, sh, 0, 0, crop\.width, crop\.height\);/, + 'shader capture should render the minimal painted ancestor and crop the selected element rect', + ); + assert.match( + SOURCE, + /function shouldUseAncestorCropShaderProxy\(el\) \{[\s\S]{0,260}?window\.__IMPECCABLE_LIVE_ADAPTER__[\s\S]{0,280}?currentPreviewMode === 'svelte-component' \|\| svelteComponentSession[\s\S]{0,260}?dataset\?\.impeccablePreview === 'svelte-component';/, + 'ancestor crop proxy must be gated to the Svelte adapter / Svelte component previews', + ); + assert.match( + SOURCE, + /if \(shouldUseAncestorCropShaderProxy\(el\)\) \{[\s\S]{0,240}?return await hideCaptureChromeForShaderProxy\(\(\) => captureElementFromRenderedAncestor\(ms, el, opts\)\);[\s\S]{0,280}?Svelte ancestor crop capture failed, falling back to element capture/, + 'Svelte ancestor crop must run before the legacy capture path and hide live chrome while doing so', + ); + assert.match( + SOURCE, + /const paper = dominantRgb01\(cctx, crop\.width, crop\.height\) \|\| averageRgb01\(cctx, crop\.width, crop\.height\);/, + 'shader paper should come from the cropped pixels so framework/backdrop composition is preserved', + ); + assert.match( + SOURCE, + /const radius = getComputedStyle\(el\)\.borderRadius;[\s\S]{0,420}?borderRadius: radius,[\s\S]{0,80}?overflow: 'hidden'/, + 'the shader canvas should clip to the selected element radius when using a rectangular ancestor crop', + ); + assert.doesNotMatch( + SOURCE, + /isSemiTransparentOwnBackground|findCompositedBackdropAncestor|compositeRgbOver|cssColorToRgba01/, + 'the shader fix should not depend on semi-transparent CSS special cases', + ); + }); + it('locks every global bar mode toggle while manual Apply is in flight', () => { assert.match( SOURCE, @@ -103,7 +141,7 @@ describe('live-browser.js regression guards', () => { it('restores unsaved inline edit drafts before hideBar tears editing down', () => { assert.match( SOURCE, - /function hideBar\(\) \{[\s\S]{0,360}?if \(state === 'EDITING'\) restoreInlineEditDrafts\(\);[\s\S]{0,80}?disableInlineEdit\(\);/, + /function hideBar\(\) \{[\s\S]{0,620}?if \(state === 'EDITING'\) restoreInlineEditDrafts\(\);[\s\S]{0,80}?disableInlineEdit\(\);/, 'hideBar should not leave unsaved contenteditable drafts in the DOM when an external event hides the bar', ); }); @@ -420,7 +458,7 @@ describe('live-browser.js regression guards', () => { ); assert.match( SOURCE, - /function handleAccept\(\)[\s\S]{0,180}?const domVisibleVariant = readVisibleVariantFromDOM\(currentSessionId\);[\s\S]{0,120}?if \(domVisibleVariant > 0\) visibleVariant = domVisibleVariant;[\s\S]{0,160}?variantId: String\(visibleVariant\)/, + /function handleAccept\(\)[\s\S]{0,360}?const domVisibleVariant = readVisibleVariantFromDOM\(currentSessionId\);[\s\S]{0,120}?if \(domVisibleVariant > 0\) visibleVariant = domVisibleVariant;[\s\S]{0,160}?variantId: String\(visibleVariant\)/, 'event=live_browser.accept_stale_visible_variant actor=browser operation=accept_after_hmr risk=accept_sends_variant_1_after_user_cycles_to_2 expected=read_dom_visible_variant actual=stale_state_variable', ); }); diff --git a/tests/live-e2e.test.mjs b/tests/live-e2e.test.mjs index 2e38698ed..d33f0f75f 100644 --- a/tests/live-e2e.test.mjs +++ b/tests/live-e2e.test.mjs @@ -32,16 +32,21 @@ import { bootFixtureSession, FIXTURES_DIR } from './live-e2e/session.mjs'; import { assertApplyDockVisible, assertApplyDockLoading, + assertAnnotationUploadEvent, assertSourceApplied, + clickExitLiveMode, clickAccept, clickApplyEdits, clickEditCopy, clickSaveEdit, clickGo, clickNext, + clickPrev, editTextLeaf, + drawAnnotationPinAndStroke, getVisibleVariant, pickElement, + runLiveChromeBottomBarSmoke, waitForApplyDockHidden, waitForBarHidden, waitForCycling, @@ -80,6 +85,8 @@ const fixtures = onlyName const manualOnly = process.env.IMPECCABLE_E2E_MANUAL_ONLY === '1' || process.env.IMPECCABLE_E2E_MANUAL_ONLY === 'true'; +const reloadVariants = process.env.IMPECCABLE_E2E_RELOAD_VARIANTS === '1' + || process.env.IMPECCABLE_E2E_RELOAD_VARIANTS === 'true'; if (fixtures.length === 0) { describe('live-e2e (no runtime fixtures registered)', () => { @@ -154,7 +161,7 @@ for (const { name, fixture } of fixtures) { fixture, browser, agent, - wrapTarget: agentMode === 'llm' ? wrapTargetFromPickedElement : undefined, + wrapTarget: wrapTargetFromPickedElement, log: (m) => t.diagnostic(m), }); @@ -163,15 +170,34 @@ for (const { name, fixture } of fixtures) { const isInsert = fixture.runtime.mode === 'insert'; const insertCfg = fixture.runtime.insert || {}; const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title'; + const insertDomSelector = agentMode === 'llm' && insertCfg.expectSelectorLlm + ? insertCfg.expectSelectorLlm + : (insertCfg.expectSelector || '.inserted-strip'); const domSelector = isInsert - ? (insertCfg.expectSelector || '.inserted-strip') + ? insertDomSelector : pickSelector; + const usesSvelteComponentPreview = fixtureUsesSvelteKitAdapter(fixture); + const variantContentSelector = isInsert + ? (usesSvelteComponentPreview ? '.inserted-copy' : '[data-impeccable-variant="2"] .inserted-copy') + : usesSvelteComponentPreview + ? pickSelector + : '[data-impeccable-variant="2"] > :first-child'; + let stateProbeBaseline = null; try { // 1. Handshake t.diagnostic('Waiting for live handshake'); await waitForHandshake(page); + if (fixture.runtime.liveChrome?.bottomBar) { + t.diagnostic('Running live chrome bottom-bar smoke'); + await runLiveChromeBottomBarSmoke(page, { + expectDetectMinCount: fixture.runtime.liveChrome.detect?.expectMinCount || 1, + designTitle: fixture.runtime.liveChrome.design?.title || '', + designRawText: fixture.runtime.liveChrome.design?.rawText || '', + }); + } + // 1b. Steer smoke — page-level chat before the heavier generate cycle. if (fixture.runtime.steer !== false) { const steerTimeouts = agentMode === 'llm' @@ -185,6 +211,9 @@ for (const { name, fixture } of fixtures) { if (fixture.runtime.preActions) { t.diagnostic(`Running ${fixture.runtime.preActions.length} preAction(s)`); await runPreActions(page, fixture.runtime.preActions); + if (fixture.runtime.stateProbe) { + stateProbeBaseline = await assertStateProbe(page, fixture.runtime.stateProbe, 'after preActions'); + } } // 3. Start generate — replace picks an element; insert places a placeholder. @@ -231,19 +260,52 @@ for (const { name, fixture } of fixtures) { preActions: fixture.runtime.preActions, log: (m) => t.diagnostic(m), }); + if (fixture.runtime.stateProbe) { + await assertStateProbe(page, fixture.runtime.stateProbe, 'after variants', { baseline: stateProbeBaseline }); + } // 5. Source-side check: wrapper + style + variants are present const sourceFile = await locateSessionFile(tmp); const after = readFileSync(sourceFile, 'utf-8'); - assert.match(after, /data-impeccable-variants="/, 'wrapper inserted'); + const svelteComponentSession = svelteComponentTargetFor(sourceFile); + if (svelteComponentSession) { + const variantFile = join(tmp, svelteComponentSession.manifest.componentDir, 'v2.svelte'); + const variantBody = readFileSync(variantFile, 'utf-8'); + const routeBody = readFileSync(join(tmp, svelteComponentSession.manifest.sourceFile), 'utf-8'); + assert.match(after, /"previewMode": "svelte-component"/, 'Svelte component manifest inserted'); + if (isInsert) { + assert.equal(svelteComponentSession.manifest.mode, 'insert', 'Svelte insert manifest marks insert mode'); + if (agentMode === 'fake') { + assert.match(variantBody, /inserted-strip/, 'Svelte insert variant component contains inserted content'); + } else if (insertCfg.expectSourcePattern) { + assert.match(variantBody, new RegExp(insertCfg.expectSourcePattern, 'i'), 'Svelte insert variant component contains prompt-matching content'); + } else { + assert.match(variantBody, /<([a-z][\w:-]*)\b[\s\S]*<\/\1>|<[a-z][\w:-]*\b[^>]*\/>/i, 'Svelte insert variant component contains a root element'); + } + } else { + assert.match(variantBody, new RegExp(`<${svelteComponentSession.expectedTag}\\b`), 'Svelte variant component contains target element'); + } + assert.doesNotMatch(routeBody, /data-impeccable-variants="/, 'Svelte route source is not edited during generation'); + } else { + assert.match(after, /data-impeccable-variants="/, 'wrapper inserted'); + } if (isInsert) { - assert.match(after, /data-impeccable-mode="insert"/, 'insert mode wrapper'); - assert.doesNotMatch(after, /data-impeccable-variant="original"/, 'insert has no original variant'); + if (svelteComponentSession) { + assert.equal(svelteComponentSession.manifest.mode, 'insert', 'Svelte insert uses component preview mode'); + } else { + assert.match(after, /data-impeccable-mode="insert"/, 'insert mode wrapper'); + assert.doesNotMatch(after, /data-impeccable-variant="original"/, 'insert has no original variant'); + } if (insertCfg.assertAnchorContains) { - assert.match(after, new RegExp(insertCfg.assertAnchorContains), 'anchor section untouched'); + const anchorSource = svelteComponentSession + ? readFileSync(join(tmp, svelteComponentSession.manifest.sourceFile), 'utf-8') + : after; + assert.match(anchorSource, new RegExp(insertCfg.assertAnchorContains), 'anchor section untouched'); } } - if (sourceFile.endsWith('.astro')) { + if (svelteComponentSession) { + assert.match(readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, 'v2.svelte'), 'utf-8'), /', + '', + ].join('\n'); + await fs.writeFile(path.join(componentDir, `v${variantId}.svelte`), component, 'utf-8'); + paramsByVariant[String(variantId)] = Array.isArray(variant.params) ? variant.params : []; + } + + await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8'); +} + +function variantMarkupHasVisibleContent(markup) { + const text = String(markup || '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (text.length > 0) return true; + return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); +} + +function buildSveltePropsScript(contract) { + if (!contract.length) return ''; + return ``; +} + +function substituteSvelteExprsWithProps(markup, contract) { + let out = String(markup || ''); + for (const entry of contract) { + out = out.split(`{${entry.expr}}`).join(`{${entry.prop}}`); + } + return out; +} + +function substituteLiveTextWithProps(markup, contract, textValues) { + let out = String(markup || ''); + for (let i = 0; i < contract.length; i++) { + const value = textValues[i]; + if (!value) continue; + out = out.split(htmlEscape(value)).join(`{${contract[i].prop}}`); + out = out.split(value).join(`{${contract[i].prop}}`); + } + return out; +} + +function extractTextPieces(html) { + return String(html || '') + .replace(//gi, '') + .replace(//gi, '') + .split(/<[^>]+>/) + .map((text) => text.replace(/\s+/g, ' ').trim()) + .filter(Boolean); +} + +function firstTagName(markup) { + const match = String(markup || '').match(/<([A-Za-z][\w:-]*)\b/); + return match ? match[1].toLowerCase() : null; +} + +function mergeTopLevelAttrs(baseMarkup, variantMarkup) { + const base = String(baseMarkup || ''); + const variant = String(variantMarkup || ''); + const baseOpen = base.match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*)(>)/); + const variantOpen = variant.match(/^\s*<([A-Za-z][\w:-]*)([^>]*)(>)/); + if (!baseOpen || !variantOpen || baseOpen[2].toLowerCase() !== variantOpen[1].toLowerCase()) return base; + return base.replace(baseOpen[0], `${baseOpen[1]}${baseOpen[2]}${variantOpen[2]}${baseOpen[4]}`); +} + +function svelteCssForVariant(scopedCss, variantId, tag) { + const css = String(scopedCss || ''); + const chunks = extractVariantCssChunks(css, variantId); + const rewritten = chunks + .join('\n') + .replace(new RegExp(String.raw`\\[data-impeccable-variant=["']${variantId}["']\\]\\s*>\\s*`, 'g'), '') + .replace(new RegExp(String.raw`\\[data-impeccable-variant=["']${variantId}["']\\][^{]*>\\s*`, 'g'), '') + .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') + .replace(/:scope(?:\[[^\]]+\])?/g, tag) + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim()) + .join('\n') + .trim(); + return rewritten || `${tag} {}`; +} + +function extractVariantCssChunks(css, variantId) { + const lines = String(css || '').split('\n'); + const chunks = []; + let collecting = false; + let depth = 0; + for (const line of lines) { + if (line.includes(`[data-impeccable-variant="${variantId}"]`) || line.includes(`[data-impeccable-variant='${variantId}']`)) { + collecting = true; + depth = 0; + if (!line.trim().startsWith('@scope')) chunks.push(line); + depth += braceDelta(line); + if (depth <= 0) collecting = false; + continue; + } + if (!collecting) continue; + const before = depth; + depth += braceDelta(line); + if (before === 1 && depth === 0 && line.trim() === '}') { + collecting = false; + continue; + } + chunks.push(line); + if (depth <= 0) collecting = false; + } + return chunks; +} + +function braceDelta(line) { + return (line.match(/\{/g) || []).length - (line.match(/\}/g) || []).length; +} + // --------------------------------------------------------------------------- // Poll loop — the "agent" runs this until aborted // --------------------------------------------------------------------------- @@ -1371,6 +1556,7 @@ export async function runAgentLoop({ type: 'steer_done', id: event.id, message: toast, + file: steerContext.targetFile, }), signal, }); @@ -1430,8 +1616,12 @@ export async function runAgentLoop({ log(`warning: agent returned ${output.variants.length} variants, expected ${event.count}`); } - // 3. Splice variants block into the wrapper (deterministic fs) - await spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId: event.id, output }); + // 3. Write variants into the deterministic preview target. + if (wrapInfo.previewMode === 'svelte-component') { + await writeSvelteComponentVariants({ tmp, wrapInfo, event, output }); + } else { + await spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId: event.id, output }); + } if (process.env.IMPECCABLE_E2E_DEBUG) { const post = await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8'); log(`--- post-splice (variants written) ---\n${post}`); @@ -1543,10 +1733,18 @@ export async function runAgentLoop({ log(`carbonize cleanup done on ${acceptResult.file}`); } + const completionType = completionTypeForAcceptResult('accept', acceptResult); await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, type: 'accept', id: event.id, data: { _acceptResult: acceptResult } }), + body: JSON.stringify({ + token, + type: completionType, + id: event.id, + file: acceptResult.file, + message: acceptResult.error, + data: acceptResult.carbonize === true ? { carbonize: true, _acceptResult: acceptResult } : { _acceptResult: acceptResult }, + }), signal, }); } catch (err) { @@ -1560,10 +1758,18 @@ export async function runAgentLoop({ log(`discard id=${event.id}`); try { const discardResult = await runAccept({ tmp, scriptsDir, id: event.id, discard: true, pageUrl: event.pageUrl }); + const completionType = completionTypeForAcceptResult('discard', discardResult); await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, type: 'discard', id: event.id, data: { _acceptResult: discardResult } }), + body: JSON.stringify({ + token, + type: completionType, + id: event.id, + file: discardResult.file, + message: discardResult.error, + data: { _acceptResult: discardResult }, + }), signal, }); } catch (err) { diff --git a/tests/live-e2e/agents/llm-agent.mjs b/tests/live-e2e/agents/llm-agent.mjs index ca84819b9..4bf854730 100644 --- a/tests/live-e2e/agents/llm-agent.mjs +++ b/tests/live-e2e/agents/llm-agent.mjs @@ -55,7 +55,7 @@ export const VARIANT_SYSTEM_INSTRUCTIONS = [ ' "scopedCss": "string — contents of the preview CSS block, authored according to wrapInfo.cssAuthoring",', ' "variants": [', ' {', - ' "innerHtml": "string — single top-level HTML element matching the picked element\'s tag, e.g.

Title

",', + ' "innerHtml": "string — single top-level HTML element; replace mode matches the picked element tag, insert mode is net-new content",', ' "params": [/* optional 0-4 ParamSpec entries */]', ' }', ' ]', @@ -67,13 +67,17 @@ export const VARIANT_SYSTEM_INSTRUCTIONS = [ ' { "id": "string", "kind": "toggle", "default": boolean, "label": "string" }', '', 'REQUIREMENTS', - '- Each variant.innerHtml must be a single top-level HTML element. Use the EXACT same tag as the picked element.', - '- The single top-level element is the replacement root itself. If the picked element is
...
, emit
...
with edited children directly; do not wrap a duplicate
inside another root.', - '- PRESERVE the original element\'s className verbatim. If the picked element\'s outerHTML contains class="hero-title", every variant\'s innerHtml MUST contain exactly class="hero-title"; do not add, remove, or rename classes. This is a hard requirement — mapped-list fixtures depend on the class string staying stable across the variant set.', - '- PRESERVE all existing visible copy exactly. GO variants change presentation, hierarchy, and styling; they must not rewrite titles, paragraphs, button labels, or user-applied manual copy edits.', - '- For bare text elements, keep the full visible copy in one editable text node. If you add child markup for styling, wrap the entire copy; never split the copy across sibling text nodes.', - '- PRESERVE existing class-bearing descendant elements in place. If the picked element contains

and

, keep those elements/classes as direct descendants of the replacement root; do not wrap them in a new structural div such as

.', - '- Do not return source-identical variants. For a bare text element, preserve the root tag/class/copy but add a small child span or styling hook so Accept persists a real source change.', + '- Replace mode: each variant.innerHtml must be a single top-level HTML element using the EXACT same tag as the picked element.', + '- Insert mode (`event.mode === "insert"`): each variant.innerHtml must be net-new content that honors event.freeformPrompt. It does NOT replace the anchor and does NOT need to use the anchor tag or preserve anchor copy.', + '- Insert mode variants must contain visible inserted content. Do not return empty roots, placeholder-only roots, inline style= attributes, or test hooks like
.', + '- Replace mode: the single top-level element is the replacement root itself. If the picked element is
...
, emit
...
with edited children directly; do not wrap a duplicate
inside another root.', + '- Replace mode: PRESERVE the original element\'s className verbatim. If the picked element\'s outerHTML contains class="hero-title", every variant\'s innerHtml MUST contain exactly class="hero-title"; do not add, remove, or rename classes. This is a hard requirement — mapped-list fixtures depend on the class string staying stable across the variant set.', + '- Replace mode: PRESERVE all existing visible copy exactly. GO variants change presentation, hierarchy, and styling; they must not rewrite titles, paragraphs, button labels, or user-applied manual copy edits.', + '- Replace mode: use the visible literal copy from the picked element. Do not emit framework template expressions or placeholders such as {name}, {amount}, ${value}, or {{value}} in innerHtml.', + '- Replace mode: for bare text elements, keep the full visible copy in one editable text node. If you add child markup for styling, wrap the entire copy; never split the copy across sibling text nodes.', + '- Replace mode: PRESERVE existing class-bearing descendant elements in place. If the picked element contains

and

, keep those elements/classes as direct descendants of the replacement root; do not wrap them in a new structural div such as

.', + '- Replace mode: Do not return source-identical variants. For a bare text element, preserve the root tag/class/copy but add a small child span or styling hook so Accept persists a real source change.', + '- Replace mode: for non-bare elements where the existing children must stay in place, add a harmless root attribute such as data-impeccable-e2e-variant="1" or another non-copy styling hook so the markup is materially changed without changing visible text.', '- Generate exactly event.count variants — no more, no fewer.', '- Mix the param kinds across the variant set: include at least one range, one steps, and one toggle when count >= 3.', '- The scopedCss must follow wrapInfo.cssAuthoring exactly: use its selector strategy, rulePattern, requirements, and forbidden patterns.', @@ -175,6 +179,7 @@ const STEER_SYSTEM_INSTRUCTIONS = [ '- Use exact find strings copied from context.sourceExcerpt or context.tagLine. Do not guess whitespace.', '- Prefer a single edit on the hero opening tag (h1 with the hero class). Preserve all existing classes and inner content.', '- file must match context.targetFile unless the excerpt clearly shows a different path is wrong.', + '- Never edit temporary preview or scratch paths such as node_modules/.impeccable-live; Steer edits must land in the real app source file.', '- edits must be non-empty; find must match exactly once in the file.', '', 'CONTEXT — live-mode skill spec follows for steer semantics (Handle steer section).', @@ -240,30 +245,12 @@ export async function createLlmAgent(opts = {}) { return { async generateVariants(event, context = {}) { + const isInsert = event.mode === 'insert'; const baseUserMessage = [ - 'Produce variants for the following pick. Reply with the JSON object only — no prose.', + `Produce variants for the following ${isInsert ? 'insert request' : 'pick'}. Reply with the JSON object only — no prose.`, '', '```json', - JSON.stringify( - { - id: event.id, - action: event.action, - count: event.count, - element: { - outerHTML: event.element?.outerHTML, - tagName: event.element?.tagName, - className: event.element?.className, - textContent: event.element?.textContent?.slice(0, 200), - }, - wrapInfo: { - styleMode: context.wrapInfo?.styleMode, - styleTag: context.wrapInfo?.styleTag, - cssAuthoring: context.wrapInfo?.cssAuthoring, - }, - }, - null, - 2, - ), + JSON.stringify(buildVariantRequestPayload(event, context), null, 2), '```', ].join('\n'); @@ -345,25 +332,41 @@ export async function createLlmAgent(opts = {}) { continue; } - const materialError = validateVariantMaterialChange(parsed, event.element); - const copyError = validateVariantVisibleCopy(parsed, event.element); - const validationError = copyError || materialError; + const validationError = isInsert + ? validateInsertVariantOutput(parsed, event) + : (validateVariantVisibleCopy(parsed, event.element) || validateVariantMaterialChange(parsed, event.element)); if (!validationError) return parsed; if (attempt === 1) throw new Error(`LLM agent: ${validationError}`); - const expectedText = normalizeVisibleText( - elementVisibleText(event.element), - ); log(`variant validation failed; retrying: ${validationError}`); - userMessage = [ - baseUserMessage, - '', - 'VALIDATION ERROR', - validationError, - `Every variant must preserve this exact normalized visible text: "${expectedText}"`, - 'Every variant must also be materially different from the picked element source. For bare text, keep the full copy in one text node; wrap the entire text in one child span or add a real styling hook.', - 'Return corrected JSON only.', - ].join('\n'); + if (isInsert) { + userMessage = [ + baseUserMessage, + '', + 'VALIDATION ERROR', + validationError, + `The inserted content must visibly satisfy this prompt: "${event.freeformPrompt || ''}"`, + 'Do not preserve or copy the anchor text unless the prompt asks for it. This is net-new content inserted near the anchor.', + 'Do not use data-impeccable-* attributes or empty test-hook-only roots.', + 'Do not use inline style= attributes; put all visual rules in scopedCss.', + 'Return corrected JSON only.', + ].join('\n'); + } else { + const expectedText = normalizeVisibleText( + elementVisibleText(event.element), + ); + userMessage = [ + baseUserMessage, + '', + 'VALIDATION ERROR', + validationError, + `Every variant must preserve this exact normalized visible text: "${expectedText}"`, + 'Use literal visible text in innerHtml, not framework placeholders like {name}, {amount}, ${value}, or {{value}}.', + 'Every variant must also be materially different from the picked element source. For bare text, keep the full copy in one text node; wrap the entire text in one child span or add a real styling hook.', + 'For non-bare markup, keep the existing visible descendants in place and add a harmless root data attribute or styling hook so the source is not identical.', + 'Return corrected JSON only.', + ].join('\n'); + } } throw new Error('LLM agent: variant generation failed'); @@ -661,6 +664,33 @@ export function validateManualEditPlanningCoverage(parsed, batch) { return null; } +export function buildVariantRequestPayload(event, context = {}) { + const isInsert = event?.mode === 'insert'; + return { + id: event?.id, + mode: event?.mode || 'replace', + action: event?.action, + freeformPrompt: event?.freeformPrompt, + count: event?.count, + element: isInsert ? null : { + outerHTML: event?.element?.outerHTML, + tagName: event?.element?.tagName, + className: event?.element?.className, + textContent: event?.element?.textContent?.slice(0, 200), + }, + insert: isInsert ? { + position: event?.insert?.position, + anchor: event?.insert?.anchor, + } : undefined, + placeholder: isInsert ? event?.placeholder : undefined, + wrapInfo: { + styleMode: context.wrapInfo?.styleMode, + styleTag: context.wrapInfo?.styleTag, + cssAuthoring: context.wrapInfo?.cssAuthoring, + }, + }; +} + /** * Parse and validate a model response into the variant-output schema. Throws * with a `Parsed (first 500 chars): ...` echo on every schema failure so the @@ -776,8 +806,10 @@ function validateScopedCss(css) { function validateVariantInnerHtml(html) { if (//.test(html)) return 'must not include HTML comments'; if (/<\/?script\b/i.test(html)) return 'must not include a